hevy-mcp 3.1.1 → 3.2.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.
- package/README.md +309 -133
- package/dist/cli.mjs +2 -2
- package/dist/index.d.mts +1 -4
- package/dist/index.mjs +2 -2
- package/dist/{src-CkkrTrrg.mjs → src-D1O3O9f2.mjs} +1883 -1363
- package/dist/src-D1O3O9f2.mjs.map +1 -0
- package/package.json +3 -3
- package/server.json +2 -2
- package/dist/src-CkkrTrrg.mjs.map +0 -1
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
(function() {
|
|
5
5
|
try {
|
|
6
6
|
var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
|
|
7
|
-
e.SENTRY_RELEASE = { id: "hevy-mcp@3.
|
|
7
|
+
e.SENTRY_RELEASE = { id: "hevy-mcp@3.2.1" };
|
|
8
8
|
var n = new e.Error().stack;
|
|
9
|
-
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "
|
|
9
|
+
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "8ce801d0-ad38-40ee-a7f3-dc829e212db8", e._sentryDebugIdIdentifier = "sentry-dbid-8ce801d0-ad38-40ee-a7f3-dc829e212db8");
|
|
10
10
|
} catch (e) {}
|
|
11
11
|
})();
|
|
12
12
|
import * as Sentry from "@sentry/node";
|
|
@@ -40,10 +40,10 @@ import semver from "semver";
|
|
|
40
40
|
* OTel Collector → Honeycomb: performance traces, metrics
|
|
41
41
|
*/
|
|
42
42
|
const name$1 = "hevy-mcp";
|
|
43
|
-
const version$1 = "3.
|
|
43
|
+
const version$1 = "3.2.1";
|
|
44
44
|
const collectorToken = "NH9vOela-HYreQxAbJa68cjEmORoEKM57EvneUDVcOo";
|
|
45
45
|
const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
|
|
46
|
-
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@3.
|
|
46
|
+
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@3.2.1`;
|
|
47
47
|
const resource = resourceFromAttributes({
|
|
48
48
|
"service.name": name$1,
|
|
49
49
|
"service.version": version$1
|
|
@@ -127,8 +127,81 @@ const stdioParseErrors = meter.createCounter("mcp.stdio.parse_errors", { descrip
|
|
|
127
127
|
/** Total server startup count, grouped by version. */
|
|
128
128
|
const serverStartups = meter.createCounter("mcp.server.startups", { description: "Total server startup count" });
|
|
129
129
|
//#endregion
|
|
130
|
+
//#region src/utils/schemas.ts
|
|
131
|
+
function coerceNullishNumberInput(value) {
|
|
132
|
+
if (value === null || value === void 0) return value;
|
|
133
|
+
if (typeof value !== "string") return value;
|
|
134
|
+
const trimmed = value.trim();
|
|
135
|
+
if (trimmed === "") return;
|
|
136
|
+
const lowered = trimmed.toLowerCase();
|
|
137
|
+
if (lowered === "null") return null;
|
|
138
|
+
if (lowered === "undefined") return;
|
|
139
|
+
const asNumber = Number(trimmed);
|
|
140
|
+
if (Number.isNaN(asNumber)) return value;
|
|
141
|
+
return asNumber;
|
|
142
|
+
}
|
|
143
|
+
const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
|
|
144
|
+
const zNullableNumber = z.preprocess((value) => value === "" ? void 0 : value, z.coerce.number().nullable().optional());
|
|
145
|
+
const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
|
|
146
|
+
start: zNullableInt,
|
|
147
|
+
end: zNullableInt
|
|
148
|
+
}).optional());
|
|
149
|
+
const setTypeEnum = z.enum([
|
|
150
|
+
"warmup",
|
|
151
|
+
"normal",
|
|
152
|
+
"failure",
|
|
153
|
+
"dropset"
|
|
154
|
+
]).default("normal");
|
|
155
|
+
const muscleGroupEnum = z.enum([
|
|
156
|
+
"abdominals",
|
|
157
|
+
"shoulders",
|
|
158
|
+
"biceps",
|
|
159
|
+
"triceps",
|
|
160
|
+
"forearms",
|
|
161
|
+
"quadriceps",
|
|
162
|
+
"hamstrings",
|
|
163
|
+
"calves",
|
|
164
|
+
"glutes",
|
|
165
|
+
"abductors",
|
|
166
|
+
"adductors",
|
|
167
|
+
"lats",
|
|
168
|
+
"upper_back",
|
|
169
|
+
"traps",
|
|
170
|
+
"lower_back",
|
|
171
|
+
"chest",
|
|
172
|
+
"cardio",
|
|
173
|
+
"neck",
|
|
174
|
+
"full_body",
|
|
175
|
+
"other"
|
|
176
|
+
]);
|
|
177
|
+
const exerciseTypeEnum = z.enum([
|
|
178
|
+
"weight_reps",
|
|
179
|
+
"reps_only",
|
|
180
|
+
"bodyweight_reps",
|
|
181
|
+
"bodyweight_assisted_reps",
|
|
182
|
+
"duration",
|
|
183
|
+
"weight_duration",
|
|
184
|
+
"distance_duration",
|
|
185
|
+
"short_distance_weight"
|
|
186
|
+
]);
|
|
187
|
+
const equipmentCategoryEnum = z.enum([
|
|
188
|
+
"none",
|
|
189
|
+
"barbell",
|
|
190
|
+
"dumbbell",
|
|
191
|
+
"kettlebell",
|
|
192
|
+
"machine",
|
|
193
|
+
"plate",
|
|
194
|
+
"resistance_band",
|
|
195
|
+
"suspension",
|
|
196
|
+
"other"
|
|
197
|
+
]);
|
|
198
|
+
const UTC_TIMESTAMP_MESSAGE = "Must use the UTC format YYYY-MM-DDTHH:mm:ssZ";
|
|
199
|
+
const utcSecondTimestamp = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, UTC_TIMESTAMP_MESSAGE).refine((value) => {
|
|
200
|
+
const parsed = new Date(value);
|
|
201
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().replace(".000Z", "Z") === value;
|
|
202
|
+
}, UTC_TIMESTAMP_MESSAGE);
|
|
203
|
+
//#endregion
|
|
130
204
|
//#region src/prompts/workouts.ts
|
|
131
|
-
const utcSecondTimestamp = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "Must use the UTC format YYYY-MM-DDTHH:mm:ssZ");
|
|
132
205
|
/** Register guided workout workflow prompts. */
|
|
133
206
|
function registerWorkoutPrompts(server) {
|
|
134
207
|
server.registerPrompt("analyze-workout-progress", {
|
|
@@ -141,9 +214,8 @@ function registerWorkoutPrompts(server) {
|
|
|
141
214
|
type: "text",
|
|
142
215
|
text: [
|
|
143
216
|
`Analyze my workout progress over the last ${weeks} weeks.`,
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"Also call get-body-measurements with pageSize=10 and paginate until the same date window is covered or no more measurements remain.",
|
|
217
|
+
"Call get-training-summary with the requested weeks; it combines recent workouts and body measurements into one compact evidence set.",
|
|
218
|
+
"Use the returned period, workout frequency, volume, exercise variety, session list, and measurement trend fields rather than issuing separate count and pagination calls.",
|
|
147
219
|
"Base the analysis on retrieved evidence and discuss workout frequency, training volume, exercise variety, consistency, and body-measurement trends.",
|
|
148
220
|
"Distinguish observations from suggestions, note missing or limited data, and do not make unsupported claims or medical conclusions."
|
|
149
221
|
].join("\n")
|
|
@@ -173,137 +245,31 @@ function registerWorkoutPrompts(server) {
|
|
|
173
245
|
}] }));
|
|
174
246
|
}
|
|
175
247
|
//#endregion
|
|
176
|
-
//#region src/utils/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
* in-flight de-duplication, and explicit refresh/invalidation support.
|
|
180
|
-
*/
|
|
181
|
-
var AsyncTtlCache = class {
|
|
182
|
-
ttlMs;
|
|
183
|
-
maxSize;
|
|
184
|
-
now;
|
|
185
|
-
entries = /* @__PURE__ */ new Map();
|
|
186
|
-
inFlight = /* @__PURE__ */ new Map();
|
|
187
|
-
requestCounter = 0;
|
|
188
|
-
constructor(options, now = Date.now) {
|
|
189
|
-
const { ttlMs, maxSize } = options;
|
|
190
|
-
if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
|
|
191
|
-
if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
|
|
192
|
-
this.ttlMs = ttlMs;
|
|
193
|
-
this.maxSize = maxSize;
|
|
194
|
-
this.now = now;
|
|
195
|
-
}
|
|
196
|
-
async getOrFetch(key, fetcher, options = {}) {
|
|
197
|
-
const { refresh = false } = options;
|
|
198
|
-
if (refresh) this.invalidate(key);
|
|
199
|
-
else {
|
|
200
|
-
const cachedEntry = this.entries.get(key);
|
|
201
|
-
if (cachedEntry !== void 0) {
|
|
202
|
-
if (cachedEntry.expiresAt > this.now()) {
|
|
203
|
-
this.markAsRecentlyUsed(key, cachedEntry);
|
|
204
|
-
return cachedEntry.value;
|
|
205
|
-
}
|
|
206
|
-
this.entries.delete(key);
|
|
207
|
-
}
|
|
208
|
-
const inFlightEntry = this.inFlight.get(key);
|
|
209
|
-
if (inFlightEntry !== void 0) return inFlightEntry.promise;
|
|
210
|
-
}
|
|
211
|
-
const requestId = ++this.requestCounter;
|
|
212
|
-
const request = (async () => {
|
|
213
|
-
try {
|
|
214
|
-
const value = await fetcher();
|
|
215
|
-
if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
|
|
216
|
-
return value;
|
|
217
|
-
} finally {
|
|
218
|
-
if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
|
|
219
|
-
}
|
|
220
|
-
})();
|
|
221
|
-
this.inFlight.set(key, {
|
|
222
|
-
promise: request,
|
|
223
|
-
requestId
|
|
224
|
-
});
|
|
225
|
-
return request;
|
|
226
|
-
}
|
|
227
|
-
invalidate(key) {
|
|
228
|
-
this.entries.delete(key);
|
|
229
|
-
this.inFlight.delete(key);
|
|
230
|
-
}
|
|
231
|
-
clear() {
|
|
232
|
-
this.entries.clear();
|
|
233
|
-
this.inFlight.clear();
|
|
234
|
-
}
|
|
235
|
-
get size() {
|
|
236
|
-
return this.entries.size;
|
|
237
|
-
}
|
|
238
|
-
isCurrentRequest(key, requestId) {
|
|
239
|
-
return this.inFlight.get(key)?.requestId === requestId;
|
|
240
|
-
}
|
|
241
|
-
markAsRecentlyUsed(key, entry) {
|
|
242
|
-
this.entries.delete(key);
|
|
243
|
-
this.entries.set(key, entry);
|
|
244
|
-
}
|
|
245
|
-
setValue(key, value) {
|
|
246
|
-
this.entries.delete(key);
|
|
247
|
-
this.entries.set(key, {
|
|
248
|
-
value,
|
|
249
|
-
expiresAt: this.now() + this.ttlMs
|
|
250
|
-
});
|
|
251
|
-
this.evictLeastRecentlyUsed();
|
|
252
|
-
}
|
|
253
|
-
evictLeastRecentlyUsed() {
|
|
254
|
-
while (this.entries.size > this.maxSize) {
|
|
255
|
-
const oldestKey = this.entries.keys().next().value;
|
|
256
|
-
if (oldestKey === void 0) return;
|
|
257
|
-
this.entries.delete(oldestKey);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
//#endregion
|
|
262
|
-
//#region src/utils/exercise-template-catalog.ts
|
|
263
|
-
const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
|
|
264
|
-
const EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS = 300 * 1e3;
|
|
265
|
-
const EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE = 1;
|
|
266
|
-
function getSafePageCount$1(data, currentPage) {
|
|
267
|
-
const pageCount = data?.page_count;
|
|
268
|
-
if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount < currentPage) return currentPage;
|
|
269
|
-
return pageCount;
|
|
270
|
-
}
|
|
271
|
-
async function fetchExerciseTemplateCatalog(hevyClient) {
|
|
272
|
-
const allTemplates = [];
|
|
248
|
+
//#region src/utils/pagination.ts
|
|
249
|
+
async function fetchAllPages(loader, pageSize) {
|
|
250
|
+
const items = [];
|
|
273
251
|
let page = 1;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
page++;
|
|
283
|
-
} while (page <= pageCount);
|
|
284
|
-
return allTemplates;
|
|
285
|
-
}
|
|
286
|
-
/** Create a cache owned by one MCP server/request lifecycle. */
|
|
287
|
-
function createExerciseTemplateCatalog() {
|
|
288
|
-
const cache = new AsyncTtlCache({
|
|
289
|
-
ttlMs: EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS,
|
|
290
|
-
maxSize: EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE
|
|
291
|
-
});
|
|
292
|
-
return {
|
|
293
|
-
get(hevyClient, options = {}) {
|
|
294
|
-
const reason = options.refresh ? "explicit-refresh" : cache.size === 0 ? "initial-load" : "ttl-expired";
|
|
295
|
-
return cache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, async () => {
|
|
296
|
-
const catalog = await fetchExerciseTemplateCatalog(hevyClient);
|
|
297
|
-
options.onRefreshed?.(catalog, reason);
|
|
298
|
-
return catalog;
|
|
299
|
-
}, options);
|
|
300
|
-
},
|
|
301
|
-
reset() {
|
|
302
|
-
cache.clear();
|
|
303
|
-
}
|
|
304
|
-
};
|
|
252
|
+
while (true) {
|
|
253
|
+
const result = await loader(page, pageSize);
|
|
254
|
+
if (result.items.length === 0) return items;
|
|
255
|
+
items.push(...result.items);
|
|
256
|
+
const pageCount = result.pageCount;
|
|
257
|
+
if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) return items;
|
|
258
|
+
page += 1;
|
|
259
|
+
}
|
|
305
260
|
}
|
|
306
261
|
//#endregion
|
|
262
|
+
//#region src/generated/client/schemas/userInfoSchema.ts
|
|
263
|
+
/**
|
|
264
|
+
* Generated by Kubb (https://kubb.dev/).
|
|
265
|
+
* Do not edit manually.
|
|
266
|
+
*/
|
|
267
|
+
const userInfoSchema = z$1.object({
|
|
268
|
+
id: z$1.optional(z$1.string().describe("The user ID.")),
|
|
269
|
+
name: z$1.optional(z$1.string().describe("The user's display name.")),
|
|
270
|
+
url: z$1.optional(z$1.string().describe("The user's public profile URL."))
|
|
271
|
+
});
|
|
272
|
+
//#endregion
|
|
307
273
|
//#region src/utils/hevy-http-error.ts
|
|
308
274
|
const HEVY_RETRY_EXHAUSTED_ERROR_CODE = "HEVY_RETRY_EXHAUSTED";
|
|
309
275
|
const HEVY_REQUEST_ABORTED_ERROR_CODE = "HEVY_REQUEST_ABORTED";
|
|
@@ -334,7 +300,7 @@ function isHevyHttpError(error) {
|
|
|
334
300
|
return error instanceof HevyHttpError;
|
|
335
301
|
}
|
|
336
302
|
//#endregion
|
|
337
|
-
//#region src/utils/
|
|
303
|
+
//#region src/utils/error-policy.ts
|
|
338
304
|
const SAFE_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
339
305
|
"EAI_AGAIN",
|
|
340
306
|
"ECONNABORTED",
|
|
@@ -385,6 +351,108 @@ const SAFE_SOURCE_SUFFIXES = [
|
|
|
385
351
|
];
|
|
386
352
|
const PROJECT_PATH_MARKER = "/hevy-mcp/";
|
|
387
353
|
const MAX_STACK_POSITION = 1e6;
|
|
354
|
+
function normalizeHeaderValue(value) {
|
|
355
|
+
if (typeof value === "string") {
|
|
356
|
+
const trimmed = value.trim();
|
|
357
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
358
|
+
}
|
|
359
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
360
|
+
if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
|
|
361
|
+
}
|
|
362
|
+
function getHeaderValue(headers, key) {
|
|
363
|
+
try {
|
|
364
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
365
|
+
if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
|
|
366
|
+
const headerRecord = headers;
|
|
367
|
+
return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Extract a valid HTTP status without retaining untrusted error metadata. */
|
|
373
|
+
function extractErrorStatus(error) {
|
|
374
|
+
try {
|
|
375
|
+
if (!isHevyHttpError(error)) return void 0;
|
|
376
|
+
return error.status !== void 0 && Number.isInteger(error.status) && error.status >= 100 && error.status <= 599 ? error.status : void 0;
|
|
377
|
+
} catch {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Return whether the client exhausted its bounded transient retry policy. */
|
|
382
|
+
function isRetryExhausted(error) {
|
|
383
|
+
try {
|
|
384
|
+
return !!error && typeof error === "object" && error.hevyRetryExhausted === true;
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Interpret a Retry-After header as seconds from the supplied current time. */
|
|
390
|
+
function getRetryAfterSeconds(error, now = Date.now()) {
|
|
391
|
+
try {
|
|
392
|
+
if (!isHevyHttpError(error)) return void 0;
|
|
393
|
+
const retryAfterHeader = getHeaderValue(error.headers, "retry-after");
|
|
394
|
+
if (!retryAfterHeader) return void 0;
|
|
395
|
+
const seconds = Number(retryAfterHeader);
|
|
396
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
|
|
397
|
+
const retryAtMillis = Date.parse(retryAfterHeader);
|
|
398
|
+
if (Number.isNaN(retryAtMillis)) return void 0;
|
|
399
|
+
return Math.ceil(Math.max(0, retryAtMillis - now) / 1e3);
|
|
400
|
+
} catch {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Map bounded Hevy HTTP statuses to stable user-facing messages. */
|
|
405
|
+
function getStatusErrorMessage(status) {
|
|
406
|
+
if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
|
|
407
|
+
if (status === 404) return "The requested resource was not found in Hevy.";
|
|
408
|
+
if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
|
|
409
|
+
if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
|
|
410
|
+
if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
|
|
411
|
+
if (status !== void 0 && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
function formatSecondsLabel(seconds) {
|
|
415
|
+
const roundedSeconds = Math.max(0, Math.round(seconds));
|
|
416
|
+
return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
|
|
417
|
+
}
|
|
418
|
+
function getRateLimitMessage(error) {
|
|
419
|
+
const seconds = getRetryAfterSeconds(error);
|
|
420
|
+
if (seconds !== void 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
|
|
421
|
+
return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
|
|
422
|
+
}
|
|
423
|
+
function getRetryExhaustedMessage(error) {
|
|
424
|
+
let retryCount;
|
|
425
|
+
try {
|
|
426
|
+
retryCount = typeof error === "object" && error !== null ? error.hevyRetryCount : void 0;
|
|
427
|
+
} catch {
|
|
428
|
+
retryCount = void 0;
|
|
429
|
+
}
|
|
430
|
+
const attemptCount = typeof retryCount === "number" && Number.isFinite(retryCount) ? retryCount + 1 : void 0;
|
|
431
|
+
if (attemptCount) return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
|
|
432
|
+
return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
|
|
433
|
+
}
|
|
434
|
+
/** Classify an error using bounded status, names, and supplied text. */
|
|
435
|
+
function determineErrorType(error, message) {
|
|
436
|
+
if (isRetryExhausted(error)) return "NETWORK_ERROR";
|
|
437
|
+
if (extractErrorStatus(error) === 429) return "RATE_LIMIT";
|
|
438
|
+
let originalMessage = "";
|
|
439
|
+
let nameLower = "";
|
|
440
|
+
try {
|
|
441
|
+
if (error instanceof Error) {
|
|
442
|
+
originalMessage = error.message.slice(0, 512);
|
|
443
|
+
nameLower = error.name.toLowerCase();
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
originalMessage = "";
|
|
447
|
+
nameLower = "";
|
|
448
|
+
}
|
|
449
|
+
const classificationText = `${message}\n${originalMessage}`.toLowerCase();
|
|
450
|
+
if (nameLower.includes("network") || classificationText.includes("network") || classificationText.includes("fetch") || classificationText.includes("timeout")) return "NETWORK_ERROR";
|
|
451
|
+
if (nameLower.includes("validation") || classificationText.includes("validation") || classificationText.includes("invalid") || classificationText.includes("required")) return "VALIDATION_ERROR";
|
|
452
|
+
if (classificationText.includes("not found") || classificationText.includes("404") || classificationText.includes("does not exist")) return "NOT_FOUND";
|
|
453
|
+
if (nameLower.includes("api") || classificationText.includes("api") || classificationText.includes("server error") || classificationText.includes("500")) return "API_ERROR";
|
|
454
|
+
return "UNKNOWN_ERROR";
|
|
455
|
+
}
|
|
388
456
|
function classifyError(error) {
|
|
389
457
|
if (isHevyHttpError(error)) return "HevyHttpError";
|
|
390
458
|
if (error instanceof TypeError) return "TypeError";
|
|
@@ -403,10 +471,6 @@ function getSafeCode(error) {
|
|
|
403
471
|
const code = error.code;
|
|
404
472
|
return typeof code === "string" && SAFE_ERROR_CODES.has(code) ? code : void 0;
|
|
405
473
|
}
|
|
406
|
-
function getSafeStatus(error) {
|
|
407
|
-
if (!isHevyHttpError(error)) return void 0;
|
|
408
|
-
return error.status !== void 0 && Number.isInteger(error.status) && error.status >= 100 && error.status <= 599 ? error.status : void 0;
|
|
409
|
-
}
|
|
410
474
|
function getSafeMethod(error) {
|
|
411
475
|
if (!isHevyHttpError(error)) return void 0;
|
|
412
476
|
const method = error.method.toUpperCase();
|
|
@@ -419,7 +483,7 @@ function getSafeEndpoint(error) {
|
|
|
419
483
|
function parseSafeStackFrames(error) {
|
|
420
484
|
if (!(error instanceof Error) || typeof error.stack !== "string") return;
|
|
421
485
|
const frames = [];
|
|
422
|
-
for (const frameLine of error.stack.split(
|
|
486
|
+
for (const frameLine of error.stack.split(/\r?\n/).slice(1)) {
|
|
423
487
|
const match = /^\s{4}at (?:[^()\r\n]+ \()?([^()\s\r\n]+):(\d+):(\d+)\)?$/.exec(frameLine);
|
|
424
488
|
if (!match) continue;
|
|
425
489
|
const [, rawSource, rawLine, rawColumn] = match;
|
|
@@ -439,11 +503,12 @@ function parseSafeStackFrames(error) {
|
|
|
439
503
|
}
|
|
440
504
|
return frames.length > 0 ? frames : void 0;
|
|
441
505
|
}
|
|
506
|
+
/** Build bounded diagnostic metadata with no raw messages, payloads, or URLs. */
|
|
442
507
|
function createSafeErrorDiagnostic(error) {
|
|
443
508
|
try {
|
|
444
509
|
const diagnostic = { category: classifyError(error) };
|
|
445
510
|
const code = getSafeCode(error);
|
|
446
|
-
const status =
|
|
511
|
+
const status = extractErrorStatus(error);
|
|
447
512
|
const method = getSafeMethod(error);
|
|
448
513
|
const endpoint = getSafeEndpoint(error);
|
|
449
514
|
const frames = parseSafeStackFrames(error);
|
|
@@ -457,8 +522,255 @@ function createSafeErrorDiagnostic(error) {
|
|
|
457
522
|
return { category: "UnknownError" };
|
|
458
523
|
}
|
|
459
524
|
}
|
|
525
|
+
/** Resolve all bounded policy outputs used by MCP, telemetry, and reporting adapters. */
|
|
526
|
+
function resolveErrorPolicy(error, defaultMessage, notInitializedMessage) {
|
|
527
|
+
const diagnostic = createSafeErrorDiagnostic(error);
|
|
528
|
+
let message = getStatusErrorMessage(diagnostic.status) ?? (diagnostic.status !== void 0 ? `Hevy API request failed (HTTP ${diagnostic.status}).` : defaultMessage);
|
|
529
|
+
let isNotInitialized = false;
|
|
530
|
+
try {
|
|
531
|
+
isNotInitialized = Boolean(notInitializedMessage) && error instanceof Error && error.message === notInitializedMessage;
|
|
532
|
+
} catch {
|
|
533
|
+
isNotInitialized = false;
|
|
534
|
+
}
|
|
535
|
+
if (isNotInitialized) message = notInitializedMessage ?? defaultMessage;
|
|
536
|
+
else if (isRetryExhausted(error)) message = getRetryExhaustedMessage(error);
|
|
537
|
+
else if (diagnostic.status === 429) message = getRateLimitMessage(error);
|
|
538
|
+
return {
|
|
539
|
+
type: determineErrorType(error, message),
|
|
540
|
+
message,
|
|
541
|
+
diagnostic
|
|
542
|
+
};
|
|
543
|
+
}
|
|
460
544
|
//#endregion
|
|
461
|
-
//#region src/utils/
|
|
545
|
+
//#region src/utils/response-formatter.ts
|
|
546
|
+
function jsonText(data) {
|
|
547
|
+
return JSON.stringify(data, null, 2) ?? "null";
|
|
548
|
+
}
|
|
549
|
+
function textContent(text) {
|
|
550
|
+
return {
|
|
551
|
+
type: "text",
|
|
552
|
+
text
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function defineStructuredResponseContract(definition) {
|
|
556
|
+
return {
|
|
557
|
+
outputSchema: definition.outputSchema,
|
|
558
|
+
render(data) {
|
|
559
|
+
const structuredContent = z.object(definition.outputSchema).parse(definition.normalize(data));
|
|
560
|
+
const text = definition.text?.(data, structuredContent) ?? jsonText(definition.legacyJson(structuredContent));
|
|
561
|
+
const additionalText = definition.additionalText?.(data, structuredContent) ?? [];
|
|
562
|
+
return {
|
|
563
|
+
content: [textContent(text), ...additionalText.map(textContent)],
|
|
564
|
+
structuredContent
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function defineJsonResponseContract(present) {
|
|
570
|
+
return { render(data) {
|
|
571
|
+
const presentation = present(data);
|
|
572
|
+
return { content: [textContent(presentation.text ?? jsonText(presentation.json)), ...(presentation.additionalText ?? []).map(textContent)] };
|
|
573
|
+
} };
|
|
574
|
+
}
|
|
575
|
+
/** The only public success-response entry point used by tool handlers. */
|
|
576
|
+
function respond(contract, data) {
|
|
577
|
+
return contract.render(data);
|
|
578
|
+
}
|
|
579
|
+
const optionalNullableNumber = z.number().nullable().optional();
|
|
580
|
+
const formattedWorkoutSetSchema = z.object({
|
|
581
|
+
index: z.number().optional(),
|
|
582
|
+
type: z.string().optional(),
|
|
583
|
+
weight: optionalNullableNumber,
|
|
584
|
+
reps: optionalNullableNumber,
|
|
585
|
+
distance: optionalNullableNumber,
|
|
586
|
+
duration: optionalNullableNumber,
|
|
587
|
+
rpe: optionalNullableNumber,
|
|
588
|
+
customMetric: optionalNullableNumber
|
|
589
|
+
});
|
|
590
|
+
const formattedWorkoutExerciseSchema = z.object({
|
|
591
|
+
index: z.number().optional(),
|
|
592
|
+
name: z.string().optional(),
|
|
593
|
+
exerciseTemplateId: z.string().optional(),
|
|
594
|
+
notes: z.string().nullable().optional(),
|
|
595
|
+
supersetsId: optionalNullableNumber,
|
|
596
|
+
sets: z.array(formattedWorkoutSetSchema).optional()
|
|
597
|
+
});
|
|
598
|
+
const formattedWorkoutSchema = z.object({
|
|
599
|
+
id: z.string().optional(),
|
|
600
|
+
title: z.string().optional(),
|
|
601
|
+
description: z.string().nullable().optional(),
|
|
602
|
+
startTime: z.union([z.string(), z.number()]).optional(),
|
|
603
|
+
endTime: z.union([z.string(), z.number()]).optional(),
|
|
604
|
+
createdAt: z.string().optional(),
|
|
605
|
+
updatedAt: z.string().optional(),
|
|
606
|
+
duration: z.string(),
|
|
607
|
+
exercises: z.array(formattedWorkoutExerciseSchema).optional()
|
|
608
|
+
});
|
|
609
|
+
const formattedRoutineSetSchema = z.object({
|
|
610
|
+
index: z.number().optional(),
|
|
611
|
+
type: z.string().optional(),
|
|
612
|
+
weight: optionalNullableNumber,
|
|
613
|
+
reps: optionalNullableNumber,
|
|
614
|
+
distance: optionalNullableNumber,
|
|
615
|
+
duration: optionalNullableNumber,
|
|
616
|
+
customMetric: optionalNullableNumber,
|
|
617
|
+
repRange: z.object({
|
|
618
|
+
start: z.number().nullable().optional(),
|
|
619
|
+
end: z.number().nullable().optional()
|
|
620
|
+
}).nullable().optional(),
|
|
621
|
+
rpe: optionalNullableNumber
|
|
622
|
+
});
|
|
623
|
+
const formattedRoutineExerciseSchema = z.object({
|
|
624
|
+
name: z.string().optional(),
|
|
625
|
+
index: z.number().optional(),
|
|
626
|
+
exerciseTemplateId: z.string().optional(),
|
|
627
|
+
notes: z.string().nullable().optional(),
|
|
628
|
+
supersetId: optionalNullableNumber,
|
|
629
|
+
restSeconds: z.union([z.string(), z.number()]).nullable().optional(),
|
|
630
|
+
sets: z.array(formattedRoutineSetSchema).optional()
|
|
631
|
+
});
|
|
632
|
+
const formattedRoutineSchema = z.object({
|
|
633
|
+
id: z.string().optional(),
|
|
634
|
+
title: z.string().optional(),
|
|
635
|
+
folderId: z.number().nullable().optional(),
|
|
636
|
+
createdAt: z.string().optional(),
|
|
637
|
+
updatedAt: z.string().optional(),
|
|
638
|
+
exercises: z.array(formattedRoutineExerciseSchema).optional()
|
|
639
|
+
});
|
|
640
|
+
const formattedRoutineFolderSchema = z.object({
|
|
641
|
+
id: z.number().optional(),
|
|
642
|
+
title: z.string().optional(),
|
|
643
|
+
createdAt: z.string().optional(),
|
|
644
|
+
updatedAt: z.string().optional()
|
|
645
|
+
});
|
|
646
|
+
const formattedExerciseTemplateSchema = z.object({
|
|
647
|
+
id: z.string().optional(),
|
|
648
|
+
title: z.string().optional(),
|
|
649
|
+
type: z.string().optional(),
|
|
650
|
+
primaryMuscleGroup: z.string().optional(),
|
|
651
|
+
secondaryMuscleGroups: z.array(z.string()).optional(),
|
|
652
|
+
isCustom: z.boolean().optional()
|
|
653
|
+
});
|
|
654
|
+
const formattedExerciseHistoryEntrySchema = z.object({
|
|
655
|
+
workoutId: z.string().optional(),
|
|
656
|
+
workoutTitle: z.string().optional(),
|
|
657
|
+
workoutStartTime: z.string().optional(),
|
|
658
|
+
workoutEndTime: z.string().optional(),
|
|
659
|
+
exerciseTemplateId: z.string().optional(),
|
|
660
|
+
weight: optionalNullableNumber,
|
|
661
|
+
reps: optionalNullableNumber,
|
|
662
|
+
distance: optionalNullableNumber,
|
|
663
|
+
duration: optionalNullableNumber,
|
|
664
|
+
rpe: optionalNullableNumber,
|
|
665
|
+
customMetric: optionalNullableNumber,
|
|
666
|
+
setType: z.string().optional()
|
|
667
|
+
});
|
|
668
|
+
const formattedBodyMeasurementSchema = z.object({
|
|
669
|
+
date: z.string(),
|
|
670
|
+
weightKg: z.number().nullable(),
|
|
671
|
+
leanMassKg: z.number().nullable(),
|
|
672
|
+
fatPercent: z.number().nullable(),
|
|
673
|
+
neckCm: z.number().nullable(),
|
|
674
|
+
shoulderCm: z.number().nullable(),
|
|
675
|
+
chestCm: z.number().nullable(),
|
|
676
|
+
leftBicepCm: z.number().nullable(),
|
|
677
|
+
rightBicepCm: z.number().nullable(),
|
|
678
|
+
leftForearmCm: z.number().nullable(),
|
|
679
|
+
rightForearmCm: z.number().nullable(),
|
|
680
|
+
abdomen: z.number().nullable(),
|
|
681
|
+
waist: z.number().nullable(),
|
|
682
|
+
hips: z.number().nullable(),
|
|
683
|
+
leftThigh: z.number().nullable(),
|
|
684
|
+
rightThigh: z.number().nullable(),
|
|
685
|
+
leftCalf: z.number().nullable(),
|
|
686
|
+
rightCalf: z.number().nullable()
|
|
687
|
+
});
|
|
688
|
+
const workflowTelemetrySchema = z.object({
|
|
689
|
+
name: z.string(),
|
|
690
|
+
pagination: z.record(z.string(), z.number().int().nonnegative()),
|
|
691
|
+
cacheStatus: z.enum([
|
|
692
|
+
"hit",
|
|
693
|
+
"miss",
|
|
694
|
+
"not-used"
|
|
695
|
+
]),
|
|
696
|
+
itemsScanned: z.number().int().nonnegative()
|
|
697
|
+
});
|
|
698
|
+
const trainingSummarySessionSchema = z.object({
|
|
699
|
+
id: z.string().optional(),
|
|
700
|
+
title: z.string().optional(),
|
|
701
|
+
startTime: z.string().optional(),
|
|
702
|
+
endTime: z.string().optional(),
|
|
703
|
+
durationSeconds: z.number().int().nonnegative().nullable(),
|
|
704
|
+
exerciseCount: z.number().int().nonnegative(),
|
|
705
|
+
setCount: z.number().int().nonnegative()
|
|
706
|
+
});
|
|
707
|
+
const compactRoutineSchema = z.object({
|
|
708
|
+
id: z.string().optional(),
|
|
709
|
+
title: z.string().optional(),
|
|
710
|
+
folderId: z.number().nullable(),
|
|
711
|
+
updatedAt: z.string().optional(),
|
|
712
|
+
exerciseCount: z.number().int().nonnegative(),
|
|
713
|
+
setCount: z.number().int().nonnegative()
|
|
714
|
+
});
|
|
715
|
+
const workoutsOutputSchema = { workouts: z.array(formattedWorkoutSchema) };
|
|
716
|
+
const workoutOutputSchema = { workout: formattedWorkoutSchema.nullable() };
|
|
717
|
+
const workoutCountOutputSchema = { count: z.number().int() };
|
|
718
|
+
const formattedUpdatedWorkoutSchema = z.object({
|
|
719
|
+
type: z.literal("updated"),
|
|
720
|
+
workout: formattedWorkoutSchema
|
|
721
|
+
});
|
|
722
|
+
const formattedDeletedWorkoutSchema = z.object({
|
|
723
|
+
type: z.literal("deleted"),
|
|
724
|
+
id: z.string(),
|
|
725
|
+
deletedAt: z.string().optional()
|
|
726
|
+
});
|
|
727
|
+
const workoutEventsOutputSchema = { events: z.array(z.union([formattedUpdatedWorkoutSchema, formattedDeletedWorkoutSchema])) };
|
|
728
|
+
const routinesOutputSchema = { routines: z.array(formattedRoutineSchema) };
|
|
729
|
+
const routineOutputSchema = { routine: formattedRoutineSchema.nullable() };
|
|
730
|
+
const exerciseTemplatesOutputSchema = { exerciseTemplates: z.array(formattedExerciseTemplateSchema) };
|
|
731
|
+
const exerciseTemplateOutputSchema = { exerciseTemplate: formattedExerciseTemplateSchema.nullable() };
|
|
732
|
+
const exerciseHistoryOutputSchema = { exerciseHistory: z.array(formattedExerciseHistoryEntrySchema) };
|
|
733
|
+
const routineFoldersOutputSchema = { routineFolders: z.array(formattedRoutineFolderSchema) };
|
|
734
|
+
const routineFolderOutputSchema = { routineFolder: formattedRoutineFolderSchema.nullable() };
|
|
735
|
+
const bodyMeasurementsOutputSchema = { bodyMeasurements: z.array(formattedBodyMeasurementSchema) };
|
|
736
|
+
const bodyMeasurementOutputSchema = { bodyMeasurement: formattedBodyMeasurementSchema.nullable() };
|
|
737
|
+
const userOutputSchema = { user: userInfoSchema.nullable() };
|
|
738
|
+
const trainingSummaryOutputSchema = {
|
|
739
|
+
period: z.object({
|
|
740
|
+
startDate: z.string(),
|
|
741
|
+
endDate: z.string(),
|
|
742
|
+
weeks: z.number().int().positive()
|
|
743
|
+
}),
|
|
744
|
+
workouts: z.object({
|
|
745
|
+
count: z.number().int().nonnegative(),
|
|
746
|
+
totalDurationSeconds: z.number().int().nonnegative(),
|
|
747
|
+
exerciseCount: z.number().int().nonnegative(),
|
|
748
|
+
setCount: z.number().int().nonnegative(),
|
|
749
|
+
uniqueExerciseTemplateIds: z.array(z.string()),
|
|
750
|
+
sessions: z.array(trainingSummarySessionSchema)
|
|
751
|
+
}),
|
|
752
|
+
bodyMeasurements: z.object({
|
|
753
|
+
count: z.number().int().nonnegative(),
|
|
754
|
+
latest: z.object({
|
|
755
|
+
date: z.string(),
|
|
756
|
+
weightKg: z.number().nullable(),
|
|
757
|
+
leanMassKg: z.number().nullable(),
|
|
758
|
+
fatPercent: z.number().nullable()
|
|
759
|
+
}).nullable(),
|
|
760
|
+
earliest: z.object({
|
|
761
|
+
date: z.string(),
|
|
762
|
+
weightKg: z.number().nullable(),
|
|
763
|
+
leanMassKg: z.number().nullable(),
|
|
764
|
+
fatPercent: z.number().nullable()
|
|
765
|
+
}).nullable(),
|
|
766
|
+
weightChangeKg: z.number().nullable()
|
|
767
|
+
}),
|
|
768
|
+
workflow: workflowTelemetrySchema
|
|
769
|
+
};
|
|
770
|
+
const compactRoutinesOutputSchema = {
|
|
771
|
+
routines: z.array(compactRoutineSchema),
|
|
772
|
+
workflow: workflowTelemetrySchema
|
|
773
|
+
};
|
|
462
774
|
function getSupersetId(exercise) {
|
|
463
775
|
if (exercise.superset_id !== void 0) return exercise.superset_id;
|
|
464
776
|
if (exercise.supersets_id !== void 0) return exercise.supersets_id;
|
|
@@ -626,16 +938,137 @@ function formatBodyMeasurement(measurement) {
|
|
|
626
938
|
rightCalf: measurement.right_calf ?? null
|
|
627
939
|
};
|
|
628
940
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
941
|
+
function formatWorkoutEvent(event) {
|
|
942
|
+
if (event.type === "updated" && "workout" in event) return {
|
|
943
|
+
type: "updated",
|
|
944
|
+
workout: formatWorkout(event.workout)
|
|
945
|
+
};
|
|
946
|
+
if (event.type === "deleted" && "id" in event) return {
|
|
947
|
+
type: "deleted",
|
|
948
|
+
id: event.id,
|
|
949
|
+
deletedAt: event.deleted_at
|
|
950
|
+
};
|
|
951
|
+
throw new Error(`Unsupported workout event type: ${event.type}`);
|
|
638
952
|
}
|
|
953
|
+
const workoutsResponse = defineStructuredResponseContract({
|
|
954
|
+
outputSchema: workoutsOutputSchema,
|
|
955
|
+
normalize: (workouts) => ({ workouts: workouts?.map(formatWorkout) ?? [] }),
|
|
956
|
+
legacyJson: ({ workouts }) => workouts,
|
|
957
|
+
text: (_data, { workouts }) => workouts.length === 0 ? "No workouts found for the specified parameters" : void 0
|
|
958
|
+
});
|
|
959
|
+
const workoutResponse = defineStructuredResponseContract({
|
|
960
|
+
outputSchema: workoutOutputSchema,
|
|
961
|
+
normalize: (data) => ({ workout: data.workout ? formatWorkout(data.workout) : null }),
|
|
962
|
+
legacyJson: ({ workout }) => workout,
|
|
963
|
+
text: ({ workoutId }, { workout }) => workout === null ? `Workout with ID ${workoutId} not found` : void 0
|
|
964
|
+
});
|
|
965
|
+
const workoutCountResponse = defineStructuredResponseContract({
|
|
966
|
+
outputSchema: workoutCountOutputSchema,
|
|
967
|
+
normalize: (count) => ({ count }),
|
|
968
|
+
legacyJson: (output) => output
|
|
969
|
+
});
|
|
970
|
+
const workoutEventsResponse = defineStructuredResponseContract({
|
|
971
|
+
outputSchema: workoutEventsOutputSchema,
|
|
972
|
+
normalize: (data) => ({ events: data.events?.map(formatWorkoutEvent) ?? [] }),
|
|
973
|
+
legacyJson: ({ events }) => events,
|
|
974
|
+
text: ({ since }, { events }) => events.length === 0 ? `No workout events found for the specified parameters since ${since}` : void 0
|
|
975
|
+
});
|
|
976
|
+
const routinesResponse = defineStructuredResponseContract({
|
|
977
|
+
outputSchema: routinesOutputSchema,
|
|
978
|
+
normalize: (routines) => ({ routines: routines?.map(formatRoutine) ?? [] }),
|
|
979
|
+
legacyJson: ({ routines }) => routines,
|
|
980
|
+
text: (_data, { routines }) => routines.length === 0 ? "No routines found for the specified parameters" : void 0
|
|
981
|
+
});
|
|
982
|
+
const routineResponse = defineStructuredResponseContract({
|
|
983
|
+
outputSchema: routineOutputSchema,
|
|
984
|
+
normalize: (data) => ({ routine: data.routine ? formatRoutine(data.routine) : null }),
|
|
985
|
+
legacyJson: ({ routine }) => routine,
|
|
986
|
+
text: ({ routineId }, { routine }) => routine === null ? `Routine with ID ${routineId} not found` : void 0
|
|
987
|
+
});
|
|
988
|
+
const exerciseTemplatesResponse = defineStructuredResponseContract({
|
|
989
|
+
outputSchema: exerciseTemplatesOutputSchema,
|
|
990
|
+
normalize: (templates) => ({ exerciseTemplates: templates?.map(formatExerciseTemplate) ?? [] }),
|
|
991
|
+
legacyJson: ({ exerciseTemplates }) => exerciseTemplates,
|
|
992
|
+
text: (_data, { exerciseTemplates }) => exerciseTemplates.length === 0 ? "No exercise templates found for the specified parameters" : void 0
|
|
993
|
+
});
|
|
994
|
+
const exerciseTemplateResponse = defineStructuredResponseContract({
|
|
995
|
+
outputSchema: exerciseTemplateOutputSchema,
|
|
996
|
+
normalize: (data) => ({ exerciseTemplate: data.exerciseTemplate ? formatExerciseTemplate(data.exerciseTemplate) : null }),
|
|
997
|
+
legacyJson: ({ exerciseTemplate }) => exerciseTemplate,
|
|
998
|
+
text: ({ exerciseTemplateId }, { exerciseTemplate }) => exerciseTemplate === null ? `Exercise template with ID ${exerciseTemplateId} not found` : void 0
|
|
999
|
+
});
|
|
1000
|
+
const exerciseHistoryResponse = defineStructuredResponseContract({
|
|
1001
|
+
outputSchema: exerciseHistoryOutputSchema,
|
|
1002
|
+
normalize: (data) => ({ exerciseHistory: data.history?.map(formatExerciseHistoryEntry) ?? [] }),
|
|
1003
|
+
legacyJson: ({ exerciseHistory }) => exerciseHistory,
|
|
1004
|
+
text: ({ exerciseTemplateId }, { exerciseHistory }) => exerciseHistory.length === 0 ? `No exercise history found for template ${exerciseTemplateId}` : void 0
|
|
1005
|
+
});
|
|
1006
|
+
const searchExerciseTemplatesResponse = defineStructuredResponseContract({
|
|
1007
|
+
outputSchema: exerciseTemplatesOutputSchema,
|
|
1008
|
+
normalize: (data) => ({ exerciseTemplates: data.results.map(formatExerciseTemplate) }),
|
|
1009
|
+
legacyJson: ({ exerciseTemplates }) => exerciseTemplates,
|
|
1010
|
+
text: ({ query, primaryMuscleGroup }, { exerciseTemplates }) => exerciseTemplates.length === 0 ? `No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}` : void 0
|
|
1011
|
+
});
|
|
1012
|
+
const routineFoldersResponse = defineStructuredResponseContract({
|
|
1013
|
+
outputSchema: routineFoldersOutputSchema,
|
|
1014
|
+
normalize: (folders) => ({ routineFolders: folders?.map(formatRoutineFolder) ?? [] }),
|
|
1015
|
+
legacyJson: ({ routineFolders }) => routineFolders,
|
|
1016
|
+
text: (_data, { routineFolders }) => routineFolders.length === 0 ? "No routine folders found for the specified parameters" : void 0
|
|
1017
|
+
});
|
|
1018
|
+
const routineFolderResponse = defineStructuredResponseContract({
|
|
1019
|
+
outputSchema: routineFolderOutputSchema,
|
|
1020
|
+
normalize: (data) => ({ routineFolder: data.routineFolder ? formatRoutineFolder(data.routineFolder) : null }),
|
|
1021
|
+
legacyJson: ({ routineFolder }) => routineFolder,
|
|
1022
|
+
text: ({ folderId }, { routineFolder }) => routineFolder === null ? `Routine folder with ID ${folderId} not found` : void 0
|
|
1023
|
+
});
|
|
1024
|
+
const bodyMeasurementsResponse = defineStructuredResponseContract({
|
|
1025
|
+
outputSchema: bodyMeasurementsOutputSchema,
|
|
1026
|
+
normalize: (measurements) => ({ bodyMeasurements: measurements?.map(formatBodyMeasurement) ?? [] }),
|
|
1027
|
+
legacyJson: ({ bodyMeasurements }) => bodyMeasurements,
|
|
1028
|
+
text: (_data, { bodyMeasurements }) => bodyMeasurements.length === 0 ? "No body measurements found for the specified parameters" : void 0
|
|
1029
|
+
});
|
|
1030
|
+
const bodyMeasurementResponse = defineStructuredResponseContract({
|
|
1031
|
+
outputSchema: bodyMeasurementOutputSchema,
|
|
1032
|
+
normalize: (data) => ({ bodyMeasurement: data.bodyMeasurement ? formatBodyMeasurement(data.bodyMeasurement) : null }),
|
|
1033
|
+
legacyJson: ({ bodyMeasurement }) => bodyMeasurement,
|
|
1034
|
+
text: ({ date }, { bodyMeasurement }) => bodyMeasurement === null ? `No body measurement found for date ${date}` : void 0
|
|
1035
|
+
});
|
|
1036
|
+
const userResponse = defineStructuredResponseContract({
|
|
1037
|
+
outputSchema: userOutputSchema,
|
|
1038
|
+
normalize: (user) => ({ user: user ?? null }),
|
|
1039
|
+
legacyJson: ({ user }) => user,
|
|
1040
|
+
text: (_data, { user }) => user === null ? "No user info found for the authenticated user" : void 0
|
|
1041
|
+
});
|
|
1042
|
+
const trainingSummaryResponse = defineStructuredResponseContract({
|
|
1043
|
+
outputSchema: trainingSummaryOutputSchema,
|
|
1044
|
+
normalize: (data) => data,
|
|
1045
|
+
legacyJson: (output) => output,
|
|
1046
|
+
text: (data) => data.workouts.count === 0 && data.bodyMeasurements.count === 0 ? "No workouts or body measurements found for the specified period" : void 0
|
|
1047
|
+
});
|
|
1048
|
+
const compactRoutinesResponse = defineStructuredResponseContract({
|
|
1049
|
+
outputSchema: compactRoutinesOutputSchema,
|
|
1050
|
+
normalize: (data) => data,
|
|
1051
|
+
legacyJson: ({ routines }) => routines,
|
|
1052
|
+
text: (_data, { routines }) => routines.length === 0 ? "No routines found matching the query" : void 0
|
|
1053
|
+
});
|
|
1054
|
+
const createWorkoutResponse = defineJsonResponseContract((workout) => workout ? { json: formatWorkout(workout) } : { text: "Failed to create workout: Server returned no data" });
|
|
1055
|
+
const updateWorkoutResponse = defineJsonResponseContract((data) => data.workout ? { json: formatWorkout(data.workout) } : { text: `Failed to update workout with ID ${data.workoutId}` });
|
|
1056
|
+
const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may not display them because they rely on an internal-only exercise field (input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for details/workarounds.";
|
|
1057
|
+
const createRoutineResponse = defineJsonResponseContract((data) => data.routine ? {
|
|
1058
|
+
json: formatRoutine(data.routine),
|
|
1059
|
+
additionalText: data.usesRepRanges ? [repRangeDisplayWarningText] : []
|
|
1060
|
+
} : { text: "Failed to create routine: Server returned no data" });
|
|
1061
|
+
const updateRoutineResponse = defineJsonResponseContract((data) => data.routine ? {
|
|
1062
|
+
json: formatRoutine(data.routine),
|
|
1063
|
+
additionalText: data.usesRepRanges ? [repRangeDisplayWarningText] : []
|
|
1064
|
+
} : { text: `Failed to update routine with ID ${data.routineId}` });
|
|
1065
|
+
const createExerciseTemplateResponse = defineJsonResponseContract((response) => ({ json: {
|
|
1066
|
+
id: response?.id,
|
|
1067
|
+
message: "Exercise template created successfully"
|
|
1068
|
+
} }));
|
|
1069
|
+
const createRoutineFolderResponse = defineJsonResponseContract((folder) => folder ? { json: formatRoutineFolder(folder) } : { text: "Failed to create routine folder: Server returned no data" });
|
|
1070
|
+
const createBodyMeasurementResponse = defineJsonResponseContract((date) => ({ text: `Body measurement for ${date} created successfully.` }));
|
|
1071
|
+
const updateBodyMeasurementResponse = defineJsonResponseContract((date) => ({ text: `Body measurement for ${date} updated successfully.` }));
|
|
639
1072
|
//#endregion
|
|
640
1073
|
//#region src/resources/hevy.ts
|
|
641
1074
|
const JSON_MIME_TYPE = "application/json";
|
|
@@ -646,57 +1079,49 @@ function createJsonResourceResult(uri, data) {
|
|
|
646
1079
|
text: JSON.stringify(data)
|
|
647
1080
|
}] };
|
|
648
1081
|
}
|
|
649
|
-
function
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
async function fetchAllRoutineFolders(hevyClient) {
|
|
655
|
-
const allFolders = [];
|
|
656
|
-
let page = 1;
|
|
657
|
-
let pageCount = 1;
|
|
658
|
-
do {
|
|
659
|
-
const data = await hevyClient.getRoutineFolders({
|
|
1082
|
+
async function fetchAllRoutineFolders(runtime) {
|
|
1083
|
+
const client = runtime.getClient();
|
|
1084
|
+
return fetchAllPages(async (page, pageSize) => {
|
|
1085
|
+
const data = await client.getRoutineFolders({
|
|
660
1086
|
page,
|
|
661
|
-
pageSize
|
|
1087
|
+
pageSize
|
|
662
1088
|
});
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1089
|
+
return {
|
|
1090
|
+
items: data?.routine_folders ?? [],
|
|
1091
|
+
pageCount: data?.page_count
|
|
1092
|
+
};
|
|
1093
|
+
}, 10);
|
|
668
1094
|
}
|
|
669
|
-
function registerHevyResources(server,
|
|
1095
|
+
function registerHevyResources(server, runtime) {
|
|
670
1096
|
server.registerResource("user-profile", "hevy://user", {
|
|
671
1097
|
description: "Authenticated Hevy user profile",
|
|
672
1098
|
mimeType: JSON_MIME_TYPE
|
|
673
1099
|
}, async (uri) => {
|
|
674
|
-
return createJsonResourceResult(uri, (await
|
|
1100
|
+
return createJsonResourceResult(uri, (await runtime.getClient().getUserInfo())?.data ?? null);
|
|
675
1101
|
});
|
|
676
1102
|
server.registerResource("workout-count", "hevy://workout-count", {
|
|
677
1103
|
description: "Total number of workouts in the Hevy account",
|
|
678
1104
|
mimeType: JSON_MIME_TYPE
|
|
679
1105
|
}, async (uri) => {
|
|
680
|
-
return createJsonResourceResult(uri, { count: (await
|
|
1106
|
+
return createJsonResourceResult(uri, { count: (await runtime.getClient().getWorkoutCount())?.workout_count ?? 0 });
|
|
681
1107
|
});
|
|
682
1108
|
server.registerResource("exercise-templates", "hevy://exercise-templates", {
|
|
683
1109
|
description: "Full formatted Hevy exercise template catalog",
|
|
684
1110
|
mimeType: JSON_MIME_TYPE
|
|
685
1111
|
}, async (uri) => {
|
|
686
|
-
|
|
687
|
-
return createJsonResourceResult(uri, (await catalog.get(client)).map(formatExerciseTemplate));
|
|
1112
|
+
return createJsonResourceResult(uri, (await runtime.catalog.get()).map(formatExerciseTemplate));
|
|
688
1113
|
});
|
|
689
1114
|
server.registerResource("routine-folders", "hevy://routine-folders", {
|
|
690
1115
|
description: "Full formatted list of Hevy routine folders",
|
|
691
1116
|
mimeType: JSON_MIME_TYPE
|
|
692
1117
|
}, async (uri) => {
|
|
693
|
-
return createJsonResourceResult(uri, (await fetchAllRoutineFolders(
|
|
1118
|
+
return createJsonResourceResult(uri, (await fetchAllRoutineFolders(runtime)).map(formatRoutineFolder));
|
|
694
1119
|
});
|
|
695
1120
|
}
|
|
696
1121
|
//#endregion
|
|
697
1122
|
//#region src/server-metadata.ts
|
|
698
1123
|
const SERVER_NAME = "hevy-mcp";
|
|
699
|
-
const SERVER_VERSION = "3.
|
|
1124
|
+
const SERVER_VERSION = "3.2.1";
|
|
700
1125
|
const SERVER_INSTRUCTIONS = [
|
|
701
1126
|
"Hevy MCP connects clients to the authenticated user's Hevy workout-tracking data, including workouts, routines, exercise templates, routine folders, body measurements, and profile information. HEVY_API_KEY must contain a valid Hevy API key for local stdio use.",
|
|
702
1127
|
"Safety: all get-* and search-* tools are read-only. create-* and update-* tools mutate Hevy data. Creates are additive and non-idempotent, so repeating one can create duplicates. Updates can overwrite existing data. Delete operations are not available.",
|
|
@@ -705,349 +1130,64 @@ const SERVER_INSTRUCTIONS = [
|
|
|
705
1130
|
"Rate limits and retries: minimize repeated calls. If Hevy returns HTTP 429, follow its retry guidance. Transient read requests retry automatically, but write requests do not; confirm uncertain write outcomes before trying again."
|
|
706
1131
|
].join("\n\n");
|
|
707
1132
|
//#endregion
|
|
708
|
-
//#region src/utils/
|
|
1133
|
+
//#region src/utils/tool-helpers.ts
|
|
709
1134
|
/**
|
|
710
|
-
*
|
|
1135
|
+
* Type-safe utilities for MCP tool handlers
|
|
711
1136
|
*/
|
|
712
|
-
|
|
713
|
-
if (error !== null && typeof error === "object" && error.hevyRetryExhausted === true) return "NETWORK_ERROR";
|
|
714
|
-
if (isHevyHttpError(error) && error.status === 429) return "RATE_LIMIT";
|
|
715
|
-
const messageLower = message.toLowerCase();
|
|
716
|
-
const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
|
|
717
|
-
if (nameLower.includes("network") || messageLower.includes("network") || nameLower.includes("fetch") || messageLower.includes("fetch") || nameLower.includes("timeout") || messageLower.includes("timeout")) return "NETWORK_ERROR";
|
|
718
|
-
if (nameLower.includes("validation") || messageLower.includes("validation") || messageLower.includes("invalid") || messageLower.includes("required")) return "VALIDATION_ERROR";
|
|
719
|
-
if (messageLower.includes("not found") || messageLower.includes("404") || messageLower.includes("does not exist")) return "NOT_FOUND";
|
|
720
|
-
if (nameLower.includes("api") || messageLower.includes("api") || messageLower.includes("server error") || messageLower.includes("500")) return "API_ERROR";
|
|
721
|
-
return "UNKNOWN_ERROR";
|
|
722
|
-
}
|
|
723
|
-
//#endregion
|
|
724
|
-
//#region src/utils/error-handler.ts
|
|
1137
|
+
const HEVY_CLIENT_NOT_INITIALIZED_ERROR = "API client not initialized. Please provide HEVY_API_KEY.";
|
|
725
1138
|
/**
|
|
726
|
-
*
|
|
1139
|
+
* Assert that the Hevy API client is initialized before running a tool.
|
|
727
1140
|
*/
|
|
728
|
-
function
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
return trimmed.length > 0 ? trimmed : void 0;
|
|
732
|
-
}
|
|
733
|
-
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
734
|
-
if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
|
|
735
|
-
}
|
|
736
|
-
function getHeaderValue(headers, key) {
|
|
737
|
-
if (!headers || typeof headers !== "object") return;
|
|
738
|
-
if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
|
|
739
|
-
const headerRecord = headers;
|
|
740
|
-
return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
|
|
741
|
-
}
|
|
742
|
-
function formatSecondsLabel(seconds) {
|
|
743
|
-
const roundedSeconds = Math.max(0, Math.round(seconds));
|
|
744
|
-
return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
|
|
745
|
-
}
|
|
746
|
-
function getRateLimitMessage(error) {
|
|
747
|
-
const retryAfterHeader = getHeaderValue(error.headers, "retry-after");
|
|
748
|
-
if (!retryAfterHeader) return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
|
|
749
|
-
const seconds = Number(retryAfterHeader);
|
|
750
|
-
if (Number.isFinite(seconds) && seconds >= 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
|
|
751
|
-
const retryAtMillis = Date.parse(retryAfterHeader);
|
|
752
|
-
if (!Number.isNaN(retryAtMillis)) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(Math.ceil(Math.max(0, retryAtMillis - Date.now()) / 1e3))} before retrying.`;
|
|
753
|
-
return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
|
|
754
|
-
}
|
|
755
|
-
function isRetryExhaustedError(error) {
|
|
756
|
-
return !!error && typeof error === "object" && error.hevyRetryExhausted === true;
|
|
757
|
-
}
|
|
758
|
-
function getRetryExhaustedMessage(error) {
|
|
759
|
-
const retryCount = typeof error === "object" && error !== null ? error.hevyRetryCount : void 0;
|
|
760
|
-
const attemptCount = typeof retryCount === "number" && Number.isFinite(retryCount) ? retryCount + 1 : void 0;
|
|
761
|
-
if (attemptCount) return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
|
|
762
|
-
return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
|
|
763
|
-
}
|
|
764
|
-
function getUserFacingMessage(error, defaultMessage) {
|
|
765
|
-
if (error instanceof Error && error.message === "API client not initialized. Please provide HEVY_API_KEY.") return HEVY_CLIENT_NOT_INITIALIZED_ERROR;
|
|
766
|
-
if (isRetryExhaustedError(error)) return getRetryExhaustedMessage(error);
|
|
767
|
-
if (isHevyHttpError(error) && error.status === 429) return getRateLimitMessage(error);
|
|
768
|
-
return defaultMessage;
|
|
1141
|
+
function requireClient(client) {
|
|
1142
|
+
if (!client) throw new Error(HEVY_CLIENT_NOT_INITIALIZED_ERROR);
|
|
1143
|
+
return client;
|
|
769
1144
|
}
|
|
770
1145
|
/**
|
|
771
|
-
* Create a
|
|
1146
|
+
* Create a type-safe tool handler that validates and narrows args to inferred types
|
|
772
1147
|
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
1148
|
+
* This function wraps a handler with automatic Zod validation, ensuring runtime
|
|
1149
|
+
* type safety while maintaining compile-time type inference.
|
|
1150
|
+
*
|
|
1151
|
+
* @param schema - Zod schema object (e.g., { page: z.number(), ... })
|
|
1152
|
+
* @param handler - Handler function that receives validated, typed parameters
|
|
1153
|
+
* @returns A handler function that accepts Record<string, unknown> and validates it
|
|
1154
|
+
*
|
|
1155
|
+
* @example
|
|
1156
|
+
* ```typescript
|
|
1157
|
+
* const schema = {
|
|
1158
|
+
* page: z.coerce.number().int().gte(1).default(1),
|
|
1159
|
+
* pageSize: z.coerce.number().int().gte(1).lte(10).default(5),
|
|
1160
|
+
* } as const;
|
|
1161
|
+
*
|
|
1162
|
+
* const handler = createTypedToolHandler(schema, async (args) => {
|
|
1163
|
+
* // args is fully typed as { page: number; pageSize: number }
|
|
1164
|
+
* const { page, pageSize } = args;
|
|
1165
|
+
* // ...
|
|
1166
|
+
* });
|
|
1167
|
+
* ```
|
|
776
1168
|
*/
|
|
777
|
-
function
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
status: safeDiagnostic.status,
|
|
782
|
-
method: safeDiagnostic.method,
|
|
783
|
-
url: safeDiagnostic.endpoint
|
|
784
|
-
} : null;
|
|
785
|
-
const mappedHevyErrorMessage = mapHevyErrorMessageByStatus(axiosErrorContext?.status);
|
|
786
|
-
if (mappedHevyErrorMessage) errorMessage = mappedHevyErrorMessage;
|
|
787
|
-
else if (isHevyHttpError(error) && error.status !== void 0) errorMessage = `Hevy API request failed (HTTP ${error.status}).`;
|
|
788
|
-
errorMessage = getUserFacingMessage(error, errorMessage);
|
|
789
|
-
const errorCode = safeDiagnostic.code;
|
|
790
|
-
const errorType = determineErrorType(error, errorMessage);
|
|
791
|
-
const errorContext = {
|
|
792
|
-
sourceContext: context,
|
|
793
|
-
originalErrorMessage: `${safeDiagnostic.category} occurred`,
|
|
794
|
-
errorCode,
|
|
795
|
-
errorType,
|
|
796
|
-
axios: axiosErrorContext ?? void 0
|
|
797
|
-
};
|
|
798
|
-
const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
|
|
799
|
-
console.error("MCP tool failure", safeDiagnostic);
|
|
800
|
-
return {
|
|
801
|
-
content: [{
|
|
802
|
-
type: "text",
|
|
803
|
-
text: formattedMessage
|
|
804
|
-
}],
|
|
805
|
-
isError: true,
|
|
806
|
-
errorContext
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
function mapHevyErrorMessageByStatus(status) {
|
|
810
|
-
if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
|
|
811
|
-
if (status === 404) return "The requested resource was not found in Hevy.";
|
|
812
|
-
if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
|
|
813
|
-
if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
|
|
814
|
-
if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
|
|
815
|
-
if (status && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
|
|
816
|
-
return null;
|
|
817
|
-
}
|
|
818
|
-
/**
|
|
819
|
-
* Wrap an async function with standardized error handling
|
|
820
|
-
*
|
|
821
|
-
* This function preserves the parameter types of the wrapped function while
|
|
822
|
-
* providing error handling. The returned function accepts Record<string, unknown>
|
|
823
|
-
* (as required by MCP SDK) but internally casts to the original parameter type.
|
|
824
|
-
*
|
|
825
|
-
* @param fn - The async function to wrap
|
|
826
|
-
* @param context - Context information for error messages
|
|
827
|
-
* @returns A function that catches errors and returns standardized error responses
|
|
828
|
-
*/
|
|
829
|
-
function withErrorHandling(fn, context, onError) {
|
|
830
|
-
return async (rawArgs) => {
|
|
831
|
-
const args = rawArgs ?? {};
|
|
832
|
-
try {
|
|
833
|
-
return await fn(args);
|
|
834
|
-
} catch (error) {
|
|
835
|
-
try {
|
|
836
|
-
onError?.(error, context, Object.keys(args).length);
|
|
837
|
-
} catch {
|
|
838
|
-
console.error("MCP error observer failure", { category: "ObserverError" });
|
|
839
|
-
}
|
|
840
|
-
return createErrorResponse(error, context);
|
|
841
|
-
}
|
|
1169
|
+
function createTypedToolHandler(schema, handler) {
|
|
1170
|
+
const zodSchema = z.object(schema);
|
|
1171
|
+
return async (args) => {
|
|
1172
|
+
return handler(zodSchema.parse(args));
|
|
842
1173
|
};
|
|
843
1174
|
}
|
|
844
1175
|
//#endregion
|
|
845
|
-
//#region src/
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
type: z.string().optional(),
|
|
861
|
-
weight: optionalNullableNumber,
|
|
862
|
-
reps: optionalNullableNumber,
|
|
863
|
-
distance: optionalNullableNumber,
|
|
864
|
-
duration: optionalNullableNumber,
|
|
865
|
-
rpe: optionalNullableNumber,
|
|
866
|
-
customMetric: optionalNullableNumber
|
|
867
|
-
});
|
|
868
|
-
const formattedWorkoutExerciseSchema = z.object({
|
|
869
|
-
index: z.number().optional(),
|
|
870
|
-
name: z.string().optional(),
|
|
871
|
-
exerciseTemplateId: z.string().optional(),
|
|
872
|
-
notes: z.string().nullable().optional(),
|
|
873
|
-
supersetsId: optionalNullableNumber,
|
|
874
|
-
sets: z.array(formattedWorkoutSetSchema).optional()
|
|
875
|
-
});
|
|
876
|
-
const formattedWorkoutSchema = z.object({
|
|
877
|
-
id: z.string().optional(),
|
|
878
|
-
title: z.string().optional(),
|
|
879
|
-
description: z.string().nullable().optional(),
|
|
880
|
-
startTime: z.union([z.string(), z.number()]).optional(),
|
|
881
|
-
endTime: z.union([z.string(), z.number()]).optional(),
|
|
882
|
-
createdAt: z.string().optional(),
|
|
883
|
-
updatedAt: z.string().optional(),
|
|
884
|
-
duration: z.string(),
|
|
885
|
-
exercises: z.array(formattedWorkoutExerciseSchema).optional()
|
|
886
|
-
});
|
|
887
|
-
const formattedRoutineSetSchema = z.object({
|
|
888
|
-
index: z.number().optional(),
|
|
889
|
-
type: z.string().optional(),
|
|
890
|
-
weight: optionalNullableNumber,
|
|
891
|
-
reps: optionalNullableNumber,
|
|
892
|
-
distance: optionalNullableNumber,
|
|
893
|
-
duration: optionalNullableNumber,
|
|
894
|
-
customMetric: optionalNullableNumber,
|
|
895
|
-
repRange: z.object({
|
|
896
|
-
start: z.number().nullable().optional(),
|
|
897
|
-
end: z.number().nullable().optional()
|
|
898
|
-
}).nullable().optional(),
|
|
899
|
-
rpe: optionalNullableNumber
|
|
900
|
-
});
|
|
901
|
-
const formattedRoutineExerciseSchema = z.object({
|
|
902
|
-
name: z.string().optional(),
|
|
903
|
-
index: z.number().optional(),
|
|
904
|
-
exerciseTemplateId: z.string().optional(),
|
|
905
|
-
notes: z.string().nullable().optional(),
|
|
906
|
-
supersetId: optionalNullableNumber,
|
|
907
|
-
restSeconds: z.union([z.string(), z.number()]).nullable().optional(),
|
|
908
|
-
sets: z.array(formattedRoutineSetSchema).optional()
|
|
909
|
-
});
|
|
910
|
-
const formattedRoutineSchema = z.object({
|
|
911
|
-
id: z.string().optional(),
|
|
912
|
-
title: z.string().optional(),
|
|
913
|
-
folderId: z.number().nullable().optional(),
|
|
914
|
-
createdAt: z.string().optional(),
|
|
915
|
-
updatedAt: z.string().optional(),
|
|
916
|
-
exercises: z.array(formattedRoutineExerciseSchema).optional()
|
|
917
|
-
});
|
|
918
|
-
const formattedRoutineFolderSchema = z.object({
|
|
919
|
-
id: z.number().optional(),
|
|
920
|
-
title: z.string().optional(),
|
|
921
|
-
createdAt: z.string().optional(),
|
|
922
|
-
updatedAt: z.string().optional()
|
|
923
|
-
});
|
|
924
|
-
const formattedExerciseTemplateSchema = z.object({
|
|
925
|
-
id: z.string().optional(),
|
|
926
|
-
title: z.string().optional(),
|
|
927
|
-
type: z.string().optional(),
|
|
928
|
-
primaryMuscleGroup: z.string().optional(),
|
|
929
|
-
secondaryMuscleGroups: z.array(z.string()).optional(),
|
|
930
|
-
isCustom: z.boolean().optional()
|
|
931
|
-
});
|
|
932
|
-
const formattedExerciseHistoryEntrySchema = z.object({
|
|
933
|
-
workoutId: z.string().optional(),
|
|
934
|
-
workoutTitle: z.string().optional(),
|
|
935
|
-
workoutStartTime: z.string().optional(),
|
|
936
|
-
workoutEndTime: z.string().optional(),
|
|
937
|
-
exerciseTemplateId: z.string().optional(),
|
|
938
|
-
weight: optionalNullableNumber,
|
|
939
|
-
reps: optionalNullableNumber,
|
|
940
|
-
distance: optionalNullableNumber,
|
|
941
|
-
duration: optionalNullableNumber,
|
|
942
|
-
rpe: optionalNullableNumber,
|
|
943
|
-
customMetric: optionalNullableNumber,
|
|
944
|
-
setType: z.string().optional()
|
|
945
|
-
});
|
|
946
|
-
const formattedBodyMeasurementSchema = z.object({
|
|
947
|
-
date: z.string(),
|
|
948
|
-
weightKg: z.number().nullable(),
|
|
949
|
-
leanMassKg: z.number().nullable(),
|
|
950
|
-
fatPercent: z.number().nullable(),
|
|
951
|
-
neckCm: z.number().nullable(),
|
|
952
|
-
shoulderCm: z.number().nullable(),
|
|
953
|
-
chestCm: z.number().nullable(),
|
|
954
|
-
leftBicepCm: z.number().nullable(),
|
|
955
|
-
rightBicepCm: z.number().nullable(),
|
|
956
|
-
leftForearmCm: z.number().nullable(),
|
|
957
|
-
rightForearmCm: z.number().nullable(),
|
|
958
|
-
abdomen: z.number().nullable(),
|
|
959
|
-
waist: z.number().nullable(),
|
|
960
|
-
hips: z.number().nullable(),
|
|
961
|
-
leftThigh: z.number().nullable(),
|
|
962
|
-
rightThigh: z.number().nullable(),
|
|
963
|
-
leftCalf: z.number().nullable(),
|
|
964
|
-
rightCalf: z.number().nullable()
|
|
965
|
-
});
|
|
966
|
-
const workoutsOutputSchema = { workouts: z.array(formattedWorkoutSchema) };
|
|
967
|
-
const workoutOutputSchema = { workout: formattedWorkoutSchema.nullable() };
|
|
968
|
-
const workoutCountOutputSchema = { count: z.number().int() };
|
|
969
|
-
const formattedUpdatedWorkoutSchema = z.object({
|
|
970
|
-
type: z.literal("updated"),
|
|
971
|
-
workout: formattedWorkoutSchema
|
|
972
|
-
});
|
|
973
|
-
const formattedDeletedWorkoutSchema = z.object({
|
|
974
|
-
type: z.literal("deleted"),
|
|
975
|
-
id: z.string(),
|
|
976
|
-
deletedAt: z.string().optional()
|
|
977
|
-
});
|
|
978
|
-
const workoutEventsOutputSchema = { events: z.array(z.union([formattedUpdatedWorkoutSchema, formattedDeletedWorkoutSchema])) };
|
|
979
|
-
const routinesOutputSchema = { routines: z.array(formattedRoutineSchema) };
|
|
980
|
-
const routineOutputSchema = { routine: formattedRoutineSchema.nullable() };
|
|
981
|
-
const exerciseTemplatesOutputSchema = { exerciseTemplates: z.array(formattedExerciseTemplateSchema) };
|
|
982
|
-
const exerciseTemplateOutputSchema = { exerciseTemplate: formattedExerciseTemplateSchema.nullable() };
|
|
983
|
-
const exerciseHistoryOutputSchema = { exerciseHistory: z.array(formattedExerciseHistoryEntrySchema) };
|
|
984
|
-
const routineFoldersOutputSchema = { routineFolders: z.array(formattedRoutineFolderSchema) };
|
|
985
|
-
const routineFolderOutputSchema = { routineFolder: formattedRoutineFolderSchema.nullable() };
|
|
986
|
-
const bodyMeasurementsOutputSchema = { bodyMeasurements: z.array(formattedBodyMeasurementSchema) };
|
|
987
|
-
const bodyMeasurementOutputSchema = { bodyMeasurement: formattedBodyMeasurementSchema.nullable() };
|
|
988
|
-
const userOutputSchema = { user: userInfoSchema.nullable() };
|
|
989
|
-
//#endregion
|
|
990
|
-
//#region src/utils/response-formatter.ts
|
|
991
|
-
/**
|
|
992
|
-
* Create a standardized success response with JSON data
|
|
993
|
-
*
|
|
994
|
-
* @param data - The data to include in the response
|
|
995
|
-
* @param options - Formatting options
|
|
996
|
-
* @returns A formatted MCP tool response with the data as JSON
|
|
997
|
-
*/
|
|
998
|
-
function createJsonResponse(data, options = {
|
|
999
|
-
pretty: true,
|
|
1000
|
-
indent: 2
|
|
1001
|
-
}) {
|
|
1002
|
-
return { content: [{
|
|
1003
|
-
type: "text",
|
|
1004
|
-
text: (options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)) ?? "null"
|
|
1005
|
-
}] };
|
|
1006
|
-
}
|
|
1007
|
-
/**
|
|
1008
|
-
* Create a JSON text response with a typed machine-readable payload.
|
|
1009
|
-
*
|
|
1010
|
-
* The text content is produced by the legacy JSON formatter so existing MCP
|
|
1011
|
-
* clients receive byte-for-byte identical output.
|
|
1012
|
-
*/
|
|
1013
|
-
function createStructuredJsonResponse(data, structuredContent, options = {
|
|
1014
|
-
pretty: true,
|
|
1015
|
-
indent: 2
|
|
1016
|
-
}) {
|
|
1017
|
-
return {
|
|
1018
|
-
...createJsonResponse(data, options),
|
|
1019
|
-
structuredContent
|
|
1020
|
-
};
|
|
1021
|
-
}
|
|
1022
|
-
/**
|
|
1023
|
-
* Create a standardized success response with text data
|
|
1024
|
-
*
|
|
1025
|
-
* @param message - The text message to include in the response
|
|
1026
|
-
* @returns A formatted MCP tool response with the text message
|
|
1027
|
-
*/
|
|
1028
|
-
function createTextResponse(message) {
|
|
1029
|
-
return { content: [{
|
|
1030
|
-
type: "text",
|
|
1031
|
-
text: message
|
|
1032
|
-
}] };
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* Create a standardized success response for empty or null results
|
|
1036
|
-
*
|
|
1037
|
-
* @param message - Optional message to include (default: "No data found")
|
|
1038
|
-
* @returns A formatted MCP tool response for empty results
|
|
1039
|
-
*/
|
|
1040
|
-
function createEmptyResponse(message = "No data found") {
|
|
1041
|
-
return createTextResponse(message);
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Create an empty/not-found text response with a valid structured payload.
|
|
1045
|
-
*/
|
|
1046
|
-
function createStructuredEmptyResponse(message, structuredContent) {
|
|
1047
|
-
return {
|
|
1048
|
-
...createEmptyResponse(message),
|
|
1049
|
-
structuredContent
|
|
1050
|
-
};
|
|
1176
|
+
//#region src/tools/define-tool.ts
|
|
1177
|
+
function registerToolDefinition(server, runtime, definition) {
|
|
1178
|
+
const directHandler = createTypedToolHandler(definition.inputSchema, async (args) => respond(definition.responseContract, await definition.execute(runtime, args)));
|
|
1179
|
+
const handler = runtime.wrapHandler(directHandler, definition.name);
|
|
1180
|
+
const callback = (args) => handler(args);
|
|
1181
|
+
if (definition.kind === "read") {
|
|
1182
|
+
server.registerTool(definition.name, {
|
|
1183
|
+
description: definition.description,
|
|
1184
|
+
inputSchema: definition.inputSchema,
|
|
1185
|
+
outputSchema: definition.outputSchema,
|
|
1186
|
+
annotations: definition.annotations
|
|
1187
|
+
}, callback);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
server.tool(definition.name, definition.description, definition.inputSchema, definition.annotations, callback);
|
|
1051
1191
|
}
|
|
1052
1192
|
//#endregion
|
|
1053
1193
|
//#region src/utils/tool-annotations.ts
|
|
@@ -1093,76 +1233,92 @@ function describeTool({ summary, aliases, useCase, importantNotes }) {
|
|
|
1093
1233
|
].join(" ");
|
|
1094
1234
|
}
|
|
1095
1235
|
//#endregion
|
|
1096
|
-
//#region src/utils/
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1236
|
+
//#region src/utils/json-parser.ts
|
|
1237
|
+
/**
|
|
1238
|
+
* Preprocessor to handle MCP clients that send JSON-stringified arrays
|
|
1239
|
+
* instead of native arrays for complex parameters.
|
|
1240
|
+
*
|
|
1241
|
+
* This is used with Zod's z.preprocess to handle cases where MCP clients
|
|
1242
|
+
* serialize complex nested structures as JSON strings.
|
|
1243
|
+
*
|
|
1244
|
+
* @param val - The value to potentially parse
|
|
1245
|
+
* @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged
|
|
1246
|
+
*/
|
|
1247
|
+
function parseJsonArray(val) {
|
|
1248
|
+
if (typeof val === "string") try {
|
|
1249
|
+
return JSON.parse(val);
|
|
1250
|
+
} catch {
|
|
1251
|
+
return val;
|
|
1252
|
+
}
|
|
1253
|
+
return val;
|
|
1108
1254
|
}
|
|
1109
|
-
const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
|
|
1110
|
-
const zNullableNumber = z.preprocess((value) => value === "" ? void 0 : value, z.coerce.number().nullable().optional());
|
|
1111
|
-
const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
|
|
1112
|
-
start: zNullableInt,
|
|
1113
|
-
end: zNullableInt
|
|
1114
|
-
}).optional());
|
|
1115
|
-
const setTypeEnum = z.enum([
|
|
1116
|
-
"warmup",
|
|
1117
|
-
"normal",
|
|
1118
|
-
"failure",
|
|
1119
|
-
"dropset"
|
|
1120
|
-
]).default("normal");
|
|
1121
|
-
const muscleGroupEnum = z.enum([
|
|
1122
|
-
"abdominals",
|
|
1123
|
-
"shoulders",
|
|
1124
|
-
"biceps",
|
|
1125
|
-
"triceps",
|
|
1126
|
-
"forearms",
|
|
1127
|
-
"quadriceps",
|
|
1128
|
-
"hamstrings",
|
|
1129
|
-
"calves",
|
|
1130
|
-
"glutes",
|
|
1131
|
-
"abductors",
|
|
1132
|
-
"adductors",
|
|
1133
|
-
"lats",
|
|
1134
|
-
"upper_back",
|
|
1135
|
-
"traps",
|
|
1136
|
-
"lower_back",
|
|
1137
|
-
"chest",
|
|
1138
|
-
"cardio",
|
|
1139
|
-
"neck",
|
|
1140
|
-
"full_body",
|
|
1141
|
-
"other"
|
|
1142
|
-
]);
|
|
1143
|
-
const exerciseTypeEnum = z.enum([
|
|
1144
|
-
"weight_reps",
|
|
1145
|
-
"reps_only",
|
|
1146
|
-
"bodyweight_reps",
|
|
1147
|
-
"bodyweight_assisted_reps",
|
|
1148
|
-
"duration",
|
|
1149
|
-
"weight_duration",
|
|
1150
|
-
"distance_duration",
|
|
1151
|
-
"short_distance_weight"
|
|
1152
|
-
]);
|
|
1153
|
-
const equipmentCategoryEnum = z.enum([
|
|
1154
|
-
"none",
|
|
1155
|
-
"barbell",
|
|
1156
|
-
"dumbbell",
|
|
1157
|
-
"kettlebell",
|
|
1158
|
-
"machine",
|
|
1159
|
-
"plate",
|
|
1160
|
-
"resistance_band",
|
|
1161
|
-
"suspension",
|
|
1162
|
-
"other"
|
|
1163
|
-
]);
|
|
1164
1255
|
//#endregion
|
|
1165
|
-
//#region src/tools/
|
|
1256
|
+
//#region src/tools/input-schemas.ts
|
|
1257
|
+
/** Build the page and pageSize fields shared by paginated tools. */
|
|
1258
|
+
function paginationShape({ defaultPageSize, maxPageSize, integerPage = true }) {
|
|
1259
|
+
const pageNumber = z.coerce.number().gte(1);
|
|
1260
|
+
return {
|
|
1261
|
+
page: integerPage ? pageNumber.int() : pageNumber,
|
|
1262
|
+
pageSize: z.coerce.number().int().gte(1).lte(maxPageSize).default(defaultPageSize)
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
const nonEmptyId = z.string().min(1);
|
|
1266
|
+
const CALENDAR_DATE_MESSAGE = "Date must be in YYYY-MM-DD format";
|
|
1267
|
+
const calendarDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, CALENDAR_DATE_MESSAGE).refine((value) => {
|
|
1268
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1269
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value);
|
|
1270
|
+
}, CALENDAR_DATE_MESSAGE);
|
|
1271
|
+
const workoutSetShape = {
|
|
1272
|
+
type: setTypeEnum,
|
|
1273
|
+
weight: z.coerce.number().optional().nullable(),
|
|
1274
|
+
weightKg: z.coerce.number().optional().nullable(),
|
|
1275
|
+
reps: z.coerce.number().int().optional().nullable(),
|
|
1276
|
+
distance: z.coerce.number().int().optional().nullable(),
|
|
1277
|
+
distanceMeters: z.coerce.number().int().optional().nullable(),
|
|
1278
|
+
duration: z.coerce.number().int().optional().nullable(),
|
|
1279
|
+
durationSeconds: z.coerce.number().int().optional().nullable(),
|
|
1280
|
+
rpe: z.coerce.number().optional().nullable(),
|
|
1281
|
+
customMetric: z.coerce.number().optional().nullable()
|
|
1282
|
+
};
|
|
1283
|
+
const workoutExerciseShape = {
|
|
1284
|
+
exerciseTemplateId: nonEmptyId,
|
|
1285
|
+
supersetId: z.coerce.number().nullable().optional(),
|
|
1286
|
+
notes: z.string().optional().nullable(),
|
|
1287
|
+
sets: z.array(z.object(workoutSetShape))
|
|
1288
|
+
};
|
|
1289
|
+
const workoutPayloadShape = {
|
|
1290
|
+
title: z.string().min(1),
|
|
1291
|
+
description: z.string().optional().nullable(),
|
|
1292
|
+
startTime: utcSecondTimestamp,
|
|
1293
|
+
endTime: utcSecondTimestamp,
|
|
1294
|
+
isPrivate: z.boolean().default(false),
|
|
1295
|
+
exercises: z.preprocess(parseJsonArray, z.array(z.object(workoutExerciseShape)))
|
|
1296
|
+
};
|
|
1297
|
+
const routineSetShape = {
|
|
1298
|
+
type: setTypeEnum,
|
|
1299
|
+
weight: z.coerce.number().optional(),
|
|
1300
|
+
weightKg: z.coerce.number().optional(),
|
|
1301
|
+
reps: zNullableInt,
|
|
1302
|
+
distance: z.coerce.number().int().optional(),
|
|
1303
|
+
distanceMeters: z.coerce.number().int().optional(),
|
|
1304
|
+
duration: z.coerce.number().int().optional(),
|
|
1305
|
+
durationSeconds: z.coerce.number().int().optional(),
|
|
1306
|
+
customMetric: z.coerce.number().optional(),
|
|
1307
|
+
repRange: zOptionalRepRange
|
|
1308
|
+
};
|
|
1309
|
+
const routineExerciseShape = {
|
|
1310
|
+
exerciseTemplateId: nonEmptyId,
|
|
1311
|
+
supersetId: z.coerce.number().nullable().optional(),
|
|
1312
|
+
restSeconds: z.coerce.number().int().min(0).optional(),
|
|
1313
|
+
notes: z.string().optional(),
|
|
1314
|
+
sets: z.array(z.object(routineSetShape))
|
|
1315
|
+
};
|
|
1316
|
+
const routinePayloadShape = {
|
|
1317
|
+
title: z.string().min(1),
|
|
1318
|
+
folderId: z.coerce.number().nullable().optional(),
|
|
1319
|
+
notes: z.string().optional(),
|
|
1320
|
+
exercises: z.preprocess(parseJsonArray, z.array(z.object(routineExerciseShape)))
|
|
1321
|
+
};
|
|
1166
1322
|
const bodyMeasurementFieldsSchema = {
|
|
1167
1323
|
weightKg: zNullableNumber.describe("Body weight in kilograms"),
|
|
1168
1324
|
leanMassKg: zNullableNumber.describe("Lean body mass in kilograms"),
|
|
@@ -1182,7 +1338,7 @@ const bodyMeasurementFieldsSchema = {
|
|
|
1182
1338
|
leftCalf: zNullableNumber.describe("Left calf circumference in centimeters"),
|
|
1183
1339
|
rightCalf: zNullableNumber.describe("Right calf circumference in centimeters")
|
|
1184
1340
|
};
|
|
1185
|
-
const
|
|
1341
|
+
const measurementFieldToApiKey = {
|
|
1186
1342
|
weightKg: "weight_kg",
|
|
1187
1343
|
leanMassKg: "lean_mass_kg",
|
|
1188
1344
|
fatPercent: "fat_percent",
|
|
@@ -1201,20 +1357,130 @@ const MEASUREMENT_FIELD_TO_API_KEY = {
|
|
|
1201
1357
|
leftCalf: "left_calf",
|
|
1202
1358
|
rightCalf: "right_calf"
|
|
1203
1359
|
};
|
|
1204
|
-
|
|
1360
|
+
//#endregion
|
|
1361
|
+
//#region src/tools/payload-mappers.ts
|
|
1362
|
+
/** Map the public camelCase workout input to the API's snake_case payload. */
|
|
1363
|
+
function buildWorkoutPayload(input) {
|
|
1364
|
+
return {
|
|
1365
|
+
title: input.title,
|
|
1366
|
+
description: input.description ?? null,
|
|
1367
|
+
start_time: input.startTime,
|
|
1368
|
+
end_time: input.endTime,
|
|
1369
|
+
is_private: input.isPrivate,
|
|
1370
|
+
exercises: input.exercises.map((exercise) => ({
|
|
1371
|
+
exercise_template_id: exercise.exerciseTemplateId,
|
|
1372
|
+
superset_id: exercise.supersetId ?? null,
|
|
1373
|
+
notes: exercise.notes ?? null,
|
|
1374
|
+
sets: exercise.sets.map((set) => ({
|
|
1375
|
+
type: set.type,
|
|
1376
|
+
weight_kg: set.weight ?? set.weightKg ?? null,
|
|
1377
|
+
reps: set.reps ?? null,
|
|
1378
|
+
distance_meters: set.distance ?? set.distanceMeters ?? null,
|
|
1379
|
+
duration_seconds: set.duration ?? set.durationSeconds ?? null,
|
|
1380
|
+
rpe: set.rpe ?? null,
|
|
1381
|
+
custom_metric: set.customMetric ?? null
|
|
1382
|
+
}))
|
|
1383
|
+
}))
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
function buildRepRange(repRange) {
|
|
1387
|
+
if (!repRange) return null;
|
|
1388
|
+
const start = repRange.start ?? void 0;
|
|
1389
|
+
const end = repRange.end ?? void 0;
|
|
1390
|
+
if (start === void 0 && end === void 0) return null;
|
|
1391
|
+
return {
|
|
1392
|
+
start,
|
|
1393
|
+
end
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function getFixedRepsFromRepRange(repRange) {
|
|
1397
|
+
if (!repRange) return null;
|
|
1398
|
+
const start = repRange.start ?? null;
|
|
1399
|
+
const end = repRange.end ?? null;
|
|
1400
|
+
if (start === null || end === null || start !== end) return null;
|
|
1401
|
+
return start;
|
|
1402
|
+
}
|
|
1403
|
+
function buildRoutineSets(sets, mode) {
|
|
1404
|
+
return sets.map((set) => {
|
|
1405
|
+
const repRange = buildRepRange(set.repRange);
|
|
1406
|
+
const reps = typeof set.reps === "number" ? set.reps : getFixedRepsFromRepRange(repRange);
|
|
1407
|
+
const common = {
|
|
1408
|
+
weight_kg: set.weight ?? set.weightKg ?? null,
|
|
1409
|
+
reps: reps ?? null,
|
|
1410
|
+
distance_meters: set.distance ?? set.distanceMeters ?? null,
|
|
1411
|
+
duration_seconds: set.duration ?? set.durationSeconds ?? null,
|
|
1412
|
+
custom_metric: set.customMetric ?? null
|
|
1413
|
+
};
|
|
1414
|
+
if (mode === "create") return {
|
|
1415
|
+
...common,
|
|
1416
|
+
type: set.type,
|
|
1417
|
+
rep_range: repRange
|
|
1418
|
+
};
|
|
1419
|
+
return {
|
|
1420
|
+
...common,
|
|
1421
|
+
type: set.type,
|
|
1422
|
+
...repRange ? { rep_range: repRange } : {}
|
|
1423
|
+
};
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
function buildRoutinePayload(input, mode) {
|
|
1427
|
+
let usesRepRanges = false;
|
|
1428
|
+
const exercises = input.exercises.map((exercise) => {
|
|
1429
|
+
const sets = buildRoutineSets(exercise.sets, mode);
|
|
1430
|
+
if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
|
|
1431
|
+
return {
|
|
1432
|
+
exercise_template_id: exercise.exerciseTemplateId,
|
|
1433
|
+
superset_id: exercise.supersetId ?? null,
|
|
1434
|
+
rest_seconds: exercise.restSeconds ?? null,
|
|
1435
|
+
notes: exercise.notes ?? null,
|
|
1436
|
+
sets
|
|
1437
|
+
};
|
|
1438
|
+
});
|
|
1439
|
+
if (mode === "create") return {
|
|
1440
|
+
payload: {
|
|
1441
|
+
title: input.title,
|
|
1442
|
+
folder_id: input.folderId ?? null,
|
|
1443
|
+
notes: input.notes ?? "",
|
|
1444
|
+
exercises
|
|
1445
|
+
},
|
|
1446
|
+
usesRepRanges
|
|
1447
|
+
};
|
|
1448
|
+
return {
|
|
1449
|
+
payload: {
|
|
1450
|
+
title: input.title,
|
|
1451
|
+
notes: input.notes ?? null,
|
|
1452
|
+
exercises
|
|
1453
|
+
},
|
|
1454
|
+
usesRepRanges
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
/** Omit nullish measurement values because the API rejects explicit nulls. */
|
|
1458
|
+
function buildMeasurementPayload(fields) {
|
|
1205
1459
|
const payload = {};
|
|
1206
|
-
for (const [camelKey, apiKey] of Object.entries(
|
|
1207
|
-
const value =
|
|
1460
|
+
for (const [camelKey, apiKey] of Object.entries(measurementFieldToApiKey)) {
|
|
1461
|
+
const value = fields[camelKey];
|
|
1208
1462
|
if (value != null) payload[apiKey] = value;
|
|
1209
1463
|
}
|
|
1210
1464
|
return payload;
|
|
1211
1465
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/tools/body-measurements.ts
|
|
1468
|
+
const getBodyMeasurementsSchema = { ...paginationShape({
|
|
1469
|
+
defaultPageSize: 10,
|
|
1470
|
+
maxPageSize: 10
|
|
1471
|
+
}) };
|
|
1472
|
+
const getBodyMeasurementSchema = { date: calendarDate.describe("The date of the body measurement (YYYY-MM-DD)") };
|
|
1473
|
+
const createBodyMeasurementSchema = {
|
|
1474
|
+
date: calendarDate.describe("The date of the body measurement (YYYY-MM-DD). Must be unique — returns 409 if an entry already exists for this date."),
|
|
1475
|
+
...bodyMeasurementFieldsSchema
|
|
1476
|
+
};
|
|
1477
|
+
const updateBodyMeasurementSchema = {
|
|
1478
|
+
date: calendarDate.describe("The date of the body measurement to update (YYYY-MM-DD). Must already exist — returns 404 otherwise."),
|
|
1479
|
+
...bodyMeasurementFieldsSchema
|
|
1480
|
+
};
|
|
1481
|
+
const bodyMeasurementToolDefinitions = [
|
|
1482
|
+
{
|
|
1483
|
+
name: "get-body-measurements",
|
|
1218
1484
|
description: describeTool({
|
|
1219
1485
|
summary: "Read-only. Lists dated body measurements for the account.",
|
|
1220
1486
|
aliases: [
|
|
@@ -1226,20 +1492,20 @@ function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErro
|
|
|
1226
1492
|
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
|
|
1227
1493
|
}),
|
|
1228
1494
|
inputSchema: getBodyMeasurementsSchema,
|
|
1229
|
-
outputSchema:
|
|
1230
|
-
annotations: readOnlyAnnotations("Get Body Measurements")
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
},
|
|
1241
|
-
|
|
1242
|
-
|
|
1495
|
+
outputSchema: bodyMeasurementsResponse.outputSchema,
|
|
1496
|
+
annotations: readOnlyAnnotations("Get Body Measurements"),
|
|
1497
|
+
kind: "read",
|
|
1498
|
+
responseContract: bodyMeasurementsResponse,
|
|
1499
|
+
execute: async (runtime, args) => {
|
|
1500
|
+
const { page, pageSize } = args;
|
|
1501
|
+
return (await runtime.getClient().getBodyMeasurements({
|
|
1502
|
+
page,
|
|
1503
|
+
pageSize
|
|
1504
|
+
}))?.body_measurements;
|
|
1505
|
+
}
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
name: "get-body-measurement",
|
|
1243
1509
|
description: describeTool({
|
|
1244
1510
|
summary: "Read-only. Retrieves the body measurement entry for one date.",
|
|
1245
1511
|
aliases: [
|
|
@@ -1251,71 +1517,79 @@ function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErro
|
|
|
1251
1517
|
importantNotes: "date must use YYYY-MM-DD; at most one entry exists per date."
|
|
1252
1518
|
}),
|
|
1253
1519
|
inputSchema: getBodyMeasurementSchema,
|
|
1254
|
-
outputSchema:
|
|
1255
|
-
annotations: readOnlyAnnotations("Get Body Measurement")
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
"
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1520
|
+
outputSchema: bodyMeasurementResponse.outputSchema,
|
|
1521
|
+
annotations: readOnlyAnnotations("Get Body Measurement"),
|
|
1522
|
+
kind: "read",
|
|
1523
|
+
responseContract: bodyMeasurementResponse,
|
|
1524
|
+
execute: async (runtime, args) => {
|
|
1525
|
+
const { date } = args;
|
|
1526
|
+
return {
|
|
1527
|
+
bodyMeasurement: await runtime.getClient().getBodyMeasurement(date),
|
|
1528
|
+
date
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
name: "create-body-measurement",
|
|
1534
|
+
description: describeTool({
|
|
1535
|
+
summary: "Writes to the Hevy account by creating a dated body measurement.",
|
|
1536
|
+
aliases: [
|
|
1537
|
+
"log weigh-in",
|
|
1538
|
+
"add body stats",
|
|
1539
|
+
"record measurements"
|
|
1540
|
+
],
|
|
1541
|
+
useCase: "Use for a date without an entry; use update-body-measurement when that date already exists.",
|
|
1542
|
+
importantNotes: "date must use YYYY-MM-DD and be unique. Null fields are omitted and cannot clear values; an existing date returns 409."
|
|
1543
|
+
}),
|
|
1544
|
+
inputSchema: createBodyMeasurementSchema,
|
|
1545
|
+
annotations: createAnnotations("Create Body Measurement"),
|
|
1546
|
+
kind: "write",
|
|
1547
|
+
responseContract: createBodyMeasurementResponse,
|
|
1548
|
+
execute: async (runtime, args) => {
|
|
1549
|
+
const { date, ...fields } = args;
|
|
1550
|
+
await runtime.getClient().createBodyMeasurement({
|
|
1551
|
+
date,
|
|
1552
|
+
...buildMeasurementPayload(fields)
|
|
1553
|
+
});
|
|
1554
|
+
return date;
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
name: "update-body-measurement",
|
|
1559
|
+
description: describeTool({
|
|
1560
|
+
summary: "Mutates the Hevy account by updating a body measurement for a date.",
|
|
1561
|
+
aliases: [
|
|
1562
|
+
"edit weigh-in",
|
|
1563
|
+
"correct body stats",
|
|
1564
|
+
"change measurements"
|
|
1565
|
+
],
|
|
1566
|
+
useCase: "Use to change fields on an existing date; use create-body-measurement for a new date.",
|
|
1567
|
+
importantNotes: "date must use YYYY-MM-DD and already exist. Provide at least one numeric field; nulls are omitted and cannot clear stored values."
|
|
1568
|
+
}),
|
|
1569
|
+
inputSchema: updateBodyMeasurementSchema,
|
|
1570
|
+
annotations: updateAnnotations("Update Body Measurement"),
|
|
1571
|
+
kind: "write",
|
|
1572
|
+
responseContract: updateBodyMeasurementResponse,
|
|
1573
|
+
execute: async (runtime, args) => {
|
|
1574
|
+
const { date, ...fields } = args;
|
|
1575
|
+
const payload = buildMeasurementPayload(fields);
|
|
1576
|
+
if (Object.keys(payload).length === 0) throw new Error("No measurement fields provided. Include at least one numeric measurement field (e.g. weightKg) to update.");
|
|
1577
|
+
await runtime.getClient().updateBodyMeasurement(date, payload);
|
|
1578
|
+
return date;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
];
|
|
1308
1582
|
//#endregion
|
|
1309
1583
|
//#region src/tools/folders.ts
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1584
|
+
const getRoutineFoldersSchema = paginationShape({
|
|
1585
|
+
defaultPageSize: 5,
|
|
1586
|
+
maxPageSize: 10
|
|
1587
|
+
});
|
|
1588
|
+
const getRoutineFolderSchema = { folderId: nonEmptyId };
|
|
1589
|
+
const createRoutineFolderSchema = { name: z.string().min(1) };
|
|
1590
|
+
const folderToolDefinitions = [
|
|
1591
|
+
{
|
|
1592
|
+
name: "get-routine-folders",
|
|
1319
1593
|
description: describeTool({
|
|
1320
1594
|
summary: "Read-only. Lists default and custom routine folders.",
|
|
1321
1595
|
aliases: [
|
|
@@ -1327,20 +1601,20 @@ function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling
|
|
|
1327
1601
|
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
|
|
1328
1602
|
}),
|
|
1329
1603
|
inputSchema: getRoutineFoldersSchema,
|
|
1330
|
-
outputSchema:
|
|
1331
|
-
annotations: readOnlyAnnotations("Get Routine Folders")
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
},
|
|
1342
|
-
|
|
1343
|
-
|
|
1604
|
+
outputSchema: routineFoldersResponse.outputSchema,
|
|
1605
|
+
annotations: readOnlyAnnotations("Get Routine Folders"),
|
|
1606
|
+
kind: "read",
|
|
1607
|
+
responseContract: routineFoldersResponse,
|
|
1608
|
+
execute: async (runtime, args) => {
|
|
1609
|
+
const { page, pageSize } = args;
|
|
1610
|
+
return (await runtime.getClient().getRoutineFolders({
|
|
1611
|
+
page,
|
|
1612
|
+
pageSize
|
|
1613
|
+
}))?.routine_folders;
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
name: "get-routine-folder",
|
|
1344
1618
|
description: describeTool({
|
|
1345
1619
|
summary: "Read-only. Retrieves one routine folder's metadata by ID.",
|
|
1346
1620
|
aliases: [
|
|
@@ -1352,161 +1626,100 @@ function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling
|
|
|
1352
1626
|
importantNotes: "Requires a folderId from get-routine-folders or a prior create response."
|
|
1353
1627
|
}),
|
|
1354
1628
|
inputSchema: getRoutineFolderSchema,
|
|
1355
|
-
outputSchema:
|
|
1356
|
-
annotations: readOnlyAnnotations("Get Routine Folder")
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
"add folder",
|
|
1370
|
-
"organize routines",
|
|
1371
|
-
"create plan group"
|
|
1372
|
-
],
|
|
1373
|
-
useCase: "Use to create an organizational destination before assigning new routines to a folderId.",
|
|
1374
|
-
importantNotes: "Requires a non-empty name. Retrying or reusing a name can create duplicate folders."
|
|
1375
|
-
}), createRoutineFolderSchema, createAnnotations("Create Routine Folder"), wrapHandler(async (args) => {
|
|
1376
|
-
const client = requireClient(hevyClient);
|
|
1377
|
-
const { name } = args;
|
|
1378
|
-
const data = await client.createRoutineFolder({ routine_folder: { title: name } });
|
|
1379
|
-
if (!data) return createEmptyResponse("Failed to create routine folder: Server returned no data");
|
|
1380
|
-
return createJsonResponse(formatRoutineFolder(data), {
|
|
1381
|
-
pretty: true,
|
|
1382
|
-
indent: 2
|
|
1383
|
-
});
|
|
1384
|
-
}, "create-routine-folder"));
|
|
1385
|
-
}
|
|
1386
|
-
//#endregion
|
|
1387
|
-
//#region src/utils/json-parser.ts
|
|
1388
|
-
/**
|
|
1389
|
-
* Preprocessor to handle MCP clients that send JSON-stringified arrays
|
|
1390
|
-
* instead of native arrays for complex parameters.
|
|
1391
|
-
*
|
|
1392
|
-
* This is used with Zod's z.preprocess to handle cases where MCP clients
|
|
1393
|
-
* serialize complex nested structures as JSON strings.
|
|
1394
|
-
*
|
|
1395
|
-
* @param val - The value to potentially parse
|
|
1396
|
-
* @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged
|
|
1397
|
-
*/
|
|
1398
|
-
function parseJsonArray(val) {
|
|
1399
|
-
if (typeof val === "string") try {
|
|
1400
|
-
return JSON.parse(val);
|
|
1401
|
-
} catch {
|
|
1402
|
-
return val;
|
|
1403
|
-
}
|
|
1404
|
-
return val;
|
|
1405
|
-
}
|
|
1406
|
-
//#endregion
|
|
1407
|
-
//#region src/tools/routines.ts
|
|
1408
|
-
function buildRepRange(repRange) {
|
|
1409
|
-
if (!repRange) return null;
|
|
1410
|
-
const start = repRange.start ?? void 0;
|
|
1411
|
-
const end = repRange.end ?? void 0;
|
|
1412
|
-
if (start === void 0 && end === void 0) return null;
|
|
1413
|
-
return {
|
|
1414
|
-
start,
|
|
1415
|
-
end
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1418
|
-
/**
|
|
1419
|
-
* Returns a fixed rep count when `repRange` is a fixed range (start and end are
|
|
1420
|
-
* both non-null and equal). Otherwise returns null.
|
|
1421
|
-
*/
|
|
1422
|
-
function getFixedRepsFromRepRange(repRange) {
|
|
1423
|
-
if (!repRange) return null;
|
|
1424
|
-
const start = repRange.start ?? null;
|
|
1425
|
-
const end = repRange.end ?? null;
|
|
1426
|
-
if (start === null || end === null) return null;
|
|
1427
|
-
if (start !== end) return null;
|
|
1428
|
-
return start;
|
|
1429
|
-
}
|
|
1430
|
-
const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may not display them because they rely on an internal-only exercise field (input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for details/workarounds.";
|
|
1431
|
-
/**
|
|
1432
|
-
* Register all routine-related tools with the MCP server
|
|
1433
|
-
*/
|
|
1434
|
-
function registerRoutineTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1435
|
-
const getRoutinesSchema = {
|
|
1436
|
-
page: z.coerce.number().int().gte(1).default(1),
|
|
1437
|
-
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
|
|
1438
|
-
};
|
|
1439
|
-
server.registerTool("get-routines", {
|
|
1629
|
+
outputSchema: routineFolderResponse.outputSchema,
|
|
1630
|
+
annotations: readOnlyAnnotations("Get Routine Folder"),
|
|
1631
|
+
kind: "read",
|
|
1632
|
+
responseContract: routineFolderResponse,
|
|
1633
|
+
execute: async (runtime, args) => {
|
|
1634
|
+
const { folderId } = args;
|
|
1635
|
+
return {
|
|
1636
|
+
routineFolder: await runtime.getClient().getRoutineFolder(folderId),
|
|
1637
|
+
folderId
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
{
|
|
1642
|
+
name: "create-routine-folder",
|
|
1440
1643
|
description: describeTool({
|
|
1441
|
-
summary: "
|
|
1644
|
+
summary: "Writes to the Hevy account by creating a new routine folder.",
|
|
1442
1645
|
aliases: [
|
|
1443
|
-
"
|
|
1444
|
-
"
|
|
1445
|
-
"
|
|
1646
|
+
"add folder",
|
|
1647
|
+
"organize routines",
|
|
1648
|
+
"create plan group"
|
|
1446
1649
|
],
|
|
1447
|
-
useCase: "Use to
|
|
1448
|
-
importantNotes: "
|
|
1650
|
+
useCase: "Use to create an organizational destination before assigning new routines to a folderId.",
|
|
1651
|
+
importantNotes: "Requires a non-empty name. Retrying or reusing a name can create duplicate folders."
|
|
1449
1652
|
}),
|
|
1450
|
-
inputSchema:
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1653
|
+
inputSchema: createRoutineFolderSchema,
|
|
1654
|
+
annotations: createAnnotations("Create Routine Folder"),
|
|
1655
|
+
kind: "write",
|
|
1656
|
+
responseContract: createRoutineFolderResponse,
|
|
1657
|
+
execute: async (runtime, args) => {
|
|
1658
|
+
const { name } = args;
|
|
1659
|
+
return runtime.getClient().createRoutineFolder({ routine_folder: { title: name } });
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
];
|
|
1663
|
+
//#endregion
|
|
1664
|
+
//#region src/tools/routines.ts
|
|
1665
|
+
const getRoutinesSchema = paginationShape({
|
|
1666
|
+
defaultPageSize: 5,
|
|
1667
|
+
maxPageSize: 10
|
|
1668
|
+
});
|
|
1669
|
+
const getRoutinesDefinition = {
|
|
1670
|
+
name: "get-routines",
|
|
1671
|
+
description: describeTool({
|
|
1672
|
+
summary: "Read-only. Lists custom and default workout routines.",
|
|
1673
|
+
aliases: [
|
|
1674
|
+
"list routines",
|
|
1675
|
+
"show workout plans",
|
|
1676
|
+
"browse saved routines"
|
|
1677
|
+
],
|
|
1678
|
+
useCase: "Use to browse routines or discover a routine ID; use get-routine for one known routine.",
|
|
1679
|
+
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
|
|
1680
|
+
}),
|
|
1681
|
+
inputSchema: getRoutinesSchema,
|
|
1682
|
+
kind: "read",
|
|
1683
|
+
outputSchema: routinesResponse.outputSchema,
|
|
1684
|
+
annotations: readOnlyAnnotations("Get Routines"),
|
|
1685
|
+
responseContract: routinesResponse,
|
|
1686
|
+
execute: async (runtime, { page, pageSize }) => {
|
|
1687
|
+
return (await runtime.getClient().getRoutines({
|
|
1457
1688
|
page,
|
|
1458
1689
|
pageSize
|
|
1459
|
-
}))?.routines
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
supersetId: z.coerce.number().nullable().optional(),
|
|
1493
|
-
restSeconds: z.coerce.number().int().min(0).optional(),
|
|
1494
|
-
notes: z.string().optional(),
|
|
1495
|
-
sets: z.array(z.object({
|
|
1496
|
-
type: setTypeEnum,
|
|
1497
|
-
weight: z.coerce.number().optional(),
|
|
1498
|
-
weightKg: z.coerce.number().optional(),
|
|
1499
|
-
reps: zNullableInt,
|
|
1500
|
-
distance: z.coerce.number().int().optional(),
|
|
1501
|
-
distanceMeters: z.coerce.number().int().optional(),
|
|
1502
|
-
duration: z.coerce.number().int().optional(),
|
|
1503
|
-
durationSeconds: z.coerce.number().int().optional(),
|
|
1504
|
-
customMetric: z.coerce.number().optional(),
|
|
1505
|
-
repRange: zOptionalRepRange
|
|
1506
|
-
}))
|
|
1507
|
-
})))
|
|
1508
|
-
};
|
|
1509
|
-
server.tool("create-routine", describeTool({
|
|
1690
|
+
}))?.routines;
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
const getRoutineSchema = { routineId: nonEmptyId };
|
|
1694
|
+
const getRoutineDefinition = {
|
|
1695
|
+
name: "get-routine",
|
|
1696
|
+
description: describeTool({
|
|
1697
|
+
summary: "Read-only. Retrieves one routine and its exercise configuration by ID.",
|
|
1698
|
+
aliases: [
|
|
1699
|
+
"show routine",
|
|
1700
|
+
"fetch workout plan",
|
|
1701
|
+
"routine details"
|
|
1702
|
+
],
|
|
1703
|
+
useCase: "Use when the routineId is known; use get-routines to browse or discover IDs.",
|
|
1704
|
+
importantNotes: "Requires a routineId from get-routines or a prior create response."
|
|
1705
|
+
}),
|
|
1706
|
+
inputSchema: getRoutineSchema,
|
|
1707
|
+
kind: "read",
|
|
1708
|
+
outputSchema: routineResponse.outputSchema,
|
|
1709
|
+
annotations: readOnlyAnnotations("Get Routine"),
|
|
1710
|
+
responseContract: routineResponse,
|
|
1711
|
+
execute: async (runtime, { routineId }) => {
|
|
1712
|
+
return {
|
|
1713
|
+
routine: (await runtime.getClient().getRoutineById(String(routineId)))?.routine,
|
|
1714
|
+
routineId
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
const routineExercisesSchema = z.preprocess(parseJsonArray, z.array(z.object(routineExerciseShape)));
|
|
1719
|
+
const createRoutineSchema = routinePayloadShape;
|
|
1720
|
+
const createRoutineDefinition = {
|
|
1721
|
+
name: "create-routine",
|
|
1722
|
+
description: describeTool({
|
|
1510
1723
|
summary: "Writes to the Hevy account by creating a new workout routine.",
|
|
1511
1724
|
aliases: [
|
|
1512
1725
|
"add routine",
|
|
@@ -1515,138 +1728,84 @@ function registerRoutineTools(server, hevyClient, wrapHandler = withErrorHandlin
|
|
|
1515
1728
|
],
|
|
1516
1729
|
useCase: "Use to create a reusable plan; use create-workout to log a completed session.",
|
|
1517
1730
|
importantNotes: "Requires exercise template IDs; folderId is optional. Retrying can create duplicates, and non-fixed rep ranges may not display in Hevy apps."
|
|
1518
|
-
}),
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
supersetId: z.coerce.number().nullable().optional(),
|
|
1569
|
-
restSeconds: z.coerce.number().int().min(0).optional(),
|
|
1570
|
-
notes: z.string().optional(),
|
|
1571
|
-
sets: z.array(z.object({
|
|
1572
|
-
type: setTypeEnum,
|
|
1573
|
-
weight: z.coerce.number().optional(),
|
|
1574
|
-
weightKg: z.coerce.number().optional(),
|
|
1575
|
-
reps: zNullableInt,
|
|
1576
|
-
distance: z.coerce.number().int().optional(),
|
|
1577
|
-
distanceMeters: z.coerce.number().int().optional(),
|
|
1578
|
-
duration: z.coerce.number().int().optional(),
|
|
1579
|
-
durationSeconds: z.coerce.number().int().optional(),
|
|
1580
|
-
customMetric: z.coerce.number().optional(),
|
|
1581
|
-
repRange: zOptionalRepRange
|
|
1582
|
-
}))
|
|
1583
|
-
})))
|
|
1584
|
-
};
|
|
1585
|
-
server.tool("update-routine", describeTool({
|
|
1586
|
-
summary: "Mutates the Hevy account by replacing an existing routine's content.",
|
|
1587
|
-
aliases: [
|
|
1588
|
-
"edit routine",
|
|
1589
|
-
"revise workout plan",
|
|
1590
|
-
"replace routine exercises"
|
|
1591
|
-
],
|
|
1592
|
-
useCase: "Use to change a known routine; use create-routine for a separate new plan.",
|
|
1593
|
-
importantNotes: "Requires routineId and the complete title and exercises payload; omitted exercises are removed. Non-fixed rep ranges may not display in Hevy apps."
|
|
1594
|
-
}), updateRoutineSchema, updateAnnotations("Update Routine"), wrapHandler(async (args) => {
|
|
1595
|
-
const client = requireClient(hevyClient);
|
|
1596
|
-
const { routineId, title, notes, exercises } = args;
|
|
1597
|
-
let usesRepRanges = false;
|
|
1598
|
-
const data = await client.updateRoutine(routineId, { routine: {
|
|
1599
|
-
title,
|
|
1600
|
-
notes: notes ?? null,
|
|
1601
|
-
exercises: exercises.map((exercise) => {
|
|
1602
|
-
const sets = exercise.sets.map((set) => {
|
|
1603
|
-
const repRange = buildRepRange(set.repRange);
|
|
1604
|
-
const fixedReps = getFixedRepsFromRepRange(repRange);
|
|
1605
|
-
const reps = typeof set.reps === "number" ? set.reps : fixedReps;
|
|
1606
|
-
return {
|
|
1607
|
-
type: set.type,
|
|
1608
|
-
weight_kg: set.weight ?? set.weightKg ?? null,
|
|
1609
|
-
reps: reps ?? null,
|
|
1610
|
-
distance_meters: set.distance ?? set.distanceMeters ?? null,
|
|
1611
|
-
duration_seconds: set.duration ?? set.durationSeconds ?? null,
|
|
1612
|
-
custom_metric: set.customMetric ?? null,
|
|
1613
|
-
...repRange ? { rep_range: repRange } : {}
|
|
1614
|
-
};
|
|
1615
|
-
});
|
|
1616
|
-
if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
|
|
1617
|
-
return {
|
|
1618
|
-
exercise_template_id: exercise.exerciseTemplateId,
|
|
1619
|
-
superset_id: exercise.supersetId ?? null,
|
|
1620
|
-
rest_seconds: exercise.restSeconds ?? null,
|
|
1621
|
-
notes: exercise.notes ?? null,
|
|
1622
|
-
sets
|
|
1623
|
-
};
|
|
1624
|
-
})
|
|
1625
|
-
} });
|
|
1626
|
-
if (!data) return createEmptyResponse(`Failed to update routine with ID ${routineId}`);
|
|
1627
|
-
const response = createJsonResponse(formatRoutine(data), {
|
|
1628
|
-
pretty: true,
|
|
1629
|
-
indent: 2
|
|
1630
|
-
});
|
|
1631
|
-
if (usesRepRanges) response.content.push({
|
|
1632
|
-
type: "text",
|
|
1633
|
-
text: repRangeDisplayWarningText
|
|
1634
|
-
});
|
|
1635
|
-
return response;
|
|
1636
|
-
}, "update-routine"));
|
|
1637
|
-
}
|
|
1731
|
+
}),
|
|
1732
|
+
inputSchema: createRoutineSchema,
|
|
1733
|
+
kind: "write",
|
|
1734
|
+
annotations: createAnnotations("Create Routine"),
|
|
1735
|
+
responseContract: createRoutineResponse,
|
|
1736
|
+
execute: async (runtime, args) => {
|
|
1737
|
+
const { payload, usesRepRanges } = buildRoutinePayload(args, "create");
|
|
1738
|
+
return {
|
|
1739
|
+
routine: await runtime.getClient().createRoutine({ routine: payload }),
|
|
1740
|
+
usesRepRanges
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
const updateRoutineSchema = {
|
|
1745
|
+
routineId: nonEmptyId,
|
|
1746
|
+
title: z.string().min(1),
|
|
1747
|
+
notes: z.string().optional(),
|
|
1748
|
+
exercises: routineExercisesSchema
|
|
1749
|
+
};
|
|
1750
|
+
const routineToolDefinitions = [
|
|
1751
|
+
getRoutinesDefinition,
|
|
1752
|
+
getRoutineDefinition,
|
|
1753
|
+
createRoutineDefinition,
|
|
1754
|
+
{
|
|
1755
|
+
name: "update-routine",
|
|
1756
|
+
description: describeTool({
|
|
1757
|
+
summary: "Mutates the Hevy account by replacing an existing routine's content.",
|
|
1758
|
+
aliases: [
|
|
1759
|
+
"edit routine",
|
|
1760
|
+
"revise workout plan",
|
|
1761
|
+
"replace routine exercises"
|
|
1762
|
+
],
|
|
1763
|
+
useCase: "Use to change a known routine; use create-routine for a separate new plan.",
|
|
1764
|
+
importantNotes: "Requires routineId and the complete title and exercises payload; omitted exercises are removed. Non-fixed rep ranges may not display in Hevy apps."
|
|
1765
|
+
}),
|
|
1766
|
+
inputSchema: updateRoutineSchema,
|
|
1767
|
+
kind: "write",
|
|
1768
|
+
annotations: updateAnnotations("Update Routine"),
|
|
1769
|
+
responseContract: updateRoutineResponse,
|
|
1770
|
+
execute: async (runtime, args) => {
|
|
1771
|
+
const { routineId } = args;
|
|
1772
|
+
const { payload, usesRepRanges } = buildRoutinePayload(args, "update");
|
|
1773
|
+
return {
|
|
1774
|
+
routine: await runtime.getClient().updateRoutine(routineId, { routine: payload }),
|
|
1775
|
+
routineId,
|
|
1776
|
+
usesRepRanges
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
];
|
|
1638
1781
|
//#endregion
|
|
1639
1782
|
//#region src/tools/templates.ts
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1783
|
+
const getExerciseTemplatesSchema = paginationShape({
|
|
1784
|
+
defaultPageSize: 5,
|
|
1785
|
+
maxPageSize: 100
|
|
1786
|
+
});
|
|
1787
|
+
const getExerciseTemplateSchema = { exerciseTemplateId: nonEmptyId };
|
|
1788
|
+
const getExerciseHistorySchema = {
|
|
1789
|
+
exerciseTemplateId: nonEmptyId,
|
|
1790
|
+
startDate: z.string().datetime({ offset: true }).describe("ISO 8601 start date for filtering history").optional(),
|
|
1791
|
+
endDate: z.string().datetime({ offset: true }).describe("ISO 8601 end date for filtering history").optional()
|
|
1792
|
+
};
|
|
1793
|
+
const createExerciseTemplateSchema = {
|
|
1794
|
+
title: z.string().min(1),
|
|
1795
|
+
exerciseType: exerciseTypeEnum,
|
|
1796
|
+
equipmentCategory: equipmentCategoryEnum,
|
|
1797
|
+
muscleGroup: muscleGroupEnum,
|
|
1798
|
+
otherMuscles: z.array(muscleGroupEnum).default([])
|
|
1799
|
+
};
|
|
1800
|
+
const searchExerciseTemplatesSchema = {
|
|
1801
|
+
query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
|
|
1802
|
+
primaryMuscleGroup: muscleGroupEnum.optional().describe("Optional filter to restrict results to a specific primary muscle group"),
|
|
1803
|
+
refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
|
|
1804
|
+
};
|
|
1805
|
+
/** Ordered exercise-template tools for composition by the shared server. */
|
|
1806
|
+
const templateToolDefinitions = [
|
|
1807
|
+
{
|
|
1808
|
+
name: "get-exercise-templates",
|
|
1650
1809
|
description: describeTool({
|
|
1651
1810
|
summary: "Read-only. Lists default and custom exercise templates with equipment and muscle metadata.",
|
|
1652
1811
|
aliases: [
|
|
@@ -1658,20 +1817,20 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1658
1817
|
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 100."
|
|
1659
1818
|
}),
|
|
1660
1819
|
inputSchema: getExerciseTemplatesSchema,
|
|
1661
|
-
outputSchema:
|
|
1662
|
-
annotations: readOnlyAnnotations("Get Exercise Templates")
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
},
|
|
1673
|
-
|
|
1674
|
-
|
|
1820
|
+
outputSchema: exerciseTemplatesResponse.outputSchema,
|
|
1821
|
+
annotations: readOnlyAnnotations("Get Exercise Templates"),
|
|
1822
|
+
kind: "read",
|
|
1823
|
+
responseContract: exerciseTemplatesResponse,
|
|
1824
|
+
execute: async (runtime, args) => {
|
|
1825
|
+
const { page, pageSize } = args;
|
|
1826
|
+
return (await runtime.getClient().getExerciseTemplates({
|
|
1827
|
+
page,
|
|
1828
|
+
pageSize
|
|
1829
|
+
}))?.exercise_templates;
|
|
1830
|
+
}
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
name: "get-exercise-template",
|
|
1675
1834
|
description: describeTool({
|
|
1676
1835
|
summary: "Read-only. Retrieves complete metadata for one exercise template by ID.",
|
|
1677
1836
|
aliases: [
|
|
@@ -1683,22 +1842,20 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1683
1842
|
importantNotes: "Requires an exerciseTemplateId from a template list, search, routine, or workout."
|
|
1684
1843
|
}),
|
|
1685
1844
|
inputSchema: getExerciseTemplateSchema,
|
|
1686
|
-
outputSchema:
|
|
1687
|
-
annotations: readOnlyAnnotations("Get Exercise Template")
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
};
|
|
1701
|
-
server.registerTool("get-exercise-history", {
|
|
1845
|
+
outputSchema: exerciseTemplateResponse.outputSchema,
|
|
1846
|
+
annotations: readOnlyAnnotations("Get Exercise Template"),
|
|
1847
|
+
kind: "read",
|
|
1848
|
+
responseContract: exerciseTemplateResponse,
|
|
1849
|
+
execute: async (runtime, args) => {
|
|
1850
|
+
const { exerciseTemplateId } = args;
|
|
1851
|
+
return {
|
|
1852
|
+
exerciseTemplate: await runtime.getClient().getExerciseTemplate(exerciseTemplateId),
|
|
1853
|
+
exerciseTemplateId
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
name: "get-exercise-history",
|
|
1702
1859
|
description: describeTool({
|
|
1703
1860
|
summary: "Read-only. Retrieves past performed sets for one exercise template.",
|
|
1704
1861
|
aliases: [
|
|
@@ -1710,54 +1867,50 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1710
1867
|
importantNotes: "Requires an exerciseTemplateId. Optional startDate and endDate must be ISO 8601 datetimes with an offset."
|
|
1711
1868
|
}),
|
|
1712
1869
|
inputSchema: getExerciseHistorySchema,
|
|
1713
|
-
outputSchema:
|
|
1714
|
-
annotations: readOnlyAnnotations("Get Exercise History")
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
"
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1870
|
+
outputSchema: exerciseHistoryResponse.outputSchema,
|
|
1871
|
+
annotations: readOnlyAnnotations("Get Exercise History"),
|
|
1872
|
+
kind: "read",
|
|
1873
|
+
responseContract: exerciseHistoryResponse,
|
|
1874
|
+
execute: async (runtime, args) => {
|
|
1875
|
+
const { exerciseTemplateId, startDate, endDate } = args;
|
|
1876
|
+
return {
|
|
1877
|
+
history: (await runtime.getClient().getExerciseHistory(exerciseTemplateId, {
|
|
1878
|
+
...startDate ? { start_date: startDate } : {},
|
|
1879
|
+
...endDate ? { end_date: endDate } : {}
|
|
1880
|
+
}))?.exercise_history,
|
|
1881
|
+
exerciseTemplateId
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
name: "create-exercise-template",
|
|
1887
|
+
description: describeTool({
|
|
1888
|
+
summary: "Writes to the Hevy account by creating a custom exercise template.",
|
|
1889
|
+
aliases: [
|
|
1890
|
+
"add custom exercise",
|
|
1891
|
+
"create movement",
|
|
1892
|
+
"define exercise"
|
|
1893
|
+
],
|
|
1894
|
+
useCase: "Use only when the needed movement is absent; search-exercise-templates should check existing templates first.",
|
|
1895
|
+
importantNotes: "Requires title, exercise type, equipment category, and primary muscle group. Retrying or reusing a title can create duplicates."
|
|
1896
|
+
}),
|
|
1897
|
+
inputSchema: createExerciseTemplateSchema,
|
|
1898
|
+
annotations: createAnnotations("Create Exercise Template"),
|
|
1899
|
+
kind: "write",
|
|
1900
|
+
responseContract: createExerciseTemplateResponse,
|
|
1901
|
+
execute: async (runtime, args) => {
|
|
1902
|
+
const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
|
|
1903
|
+
return await runtime.getClient().createExerciseTemplate({ exercise: {
|
|
1746
1904
|
title,
|
|
1747
1905
|
exercise_type: exerciseType,
|
|
1748
1906
|
equipment_category: equipmentCategory,
|
|
1749
1907
|
muscle_group: muscleGroup,
|
|
1750
1908
|
other_muscles: otherMuscles
|
|
1751
|
-
} })
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
|
|
1757
|
-
primaryMuscleGroup: muscleGroupEnum.optional().describe("Optional filter to restrict results to a specific primary muscle group"),
|
|
1758
|
-
refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
|
|
1759
|
-
};
|
|
1760
|
-
server.registerTool("search-exercise-templates", {
|
|
1909
|
+
} });
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
name: "search-exercise-templates",
|
|
1761
1914
|
description: describeTool({
|
|
1762
1915
|
summary: "Read-only for the Hevy account. Searches the full exercise template catalog by title substring.",
|
|
1763
1916
|
aliases: [
|
|
@@ -1769,322 +1922,650 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1769
1922
|
importantNotes: "Matching is case-insensitive. The catalog is cached locally for 5 minutes; refresh:true re-fetches all pages and changes only local cache state."
|
|
1770
1923
|
}),
|
|
1771
1924
|
inputSchema: searchExerciseTemplatesSchema,
|
|
1772
|
-
outputSchema:
|
|
1773
|
-
annotations: readOnlyAnnotations("Search Exercise Templates")
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
refresh
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
logger
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1925
|
+
outputSchema: searchExerciseTemplatesResponse.outputSchema,
|
|
1926
|
+
annotations: readOnlyAnnotations("Search Exercise Templates"),
|
|
1927
|
+
kind: "read",
|
|
1928
|
+
responseContract: searchExerciseTemplatesResponse,
|
|
1929
|
+
execute: async (runtime, args) => {
|
|
1930
|
+
runtime.getClient();
|
|
1931
|
+
const { query, primaryMuscleGroup, refresh } = args;
|
|
1932
|
+
const templates = await runtime.catalog.get({
|
|
1933
|
+
refresh,
|
|
1934
|
+
onRefreshed: (refreshedCatalog, reason) => {
|
|
1935
|
+
try {
|
|
1936
|
+
runtime.logger?.({
|
|
1937
|
+
level: "info",
|
|
1938
|
+
logger: "hevy-cache",
|
|
1939
|
+
data: {
|
|
1940
|
+
message: "Exercise template catalog refreshed",
|
|
1941
|
+
count: refreshedCatalog.length,
|
|
1942
|
+
reason
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
console.error("Failed to emit structured exercise template cache log", createSafeErrorDiagnostic(error));
|
|
1947
|
+
}
|
|
1792
1948
|
}
|
|
1793
|
-
}
|
|
1949
|
+
});
|
|
1950
|
+
const queryLower = query.toLowerCase();
|
|
1951
|
+
let results = templates.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
|
|
1952
|
+
if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
|
|
1953
|
+
return {
|
|
1954
|
+
results,
|
|
1955
|
+
query,
|
|
1956
|
+
primaryMuscleGroup
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
];
|
|
1961
|
+
const userToolDefinitions = [{
|
|
1962
|
+
name: "get-user-info",
|
|
1963
|
+
description: describeTool({
|
|
1964
|
+
summary: "Read-only. Returns the authenticated account's user ID, display name, and public profile URL.",
|
|
1965
|
+
aliases: [
|
|
1966
|
+
"who am I",
|
|
1967
|
+
"account info",
|
|
1968
|
+
"verify Hevy user"
|
|
1969
|
+
],
|
|
1970
|
+
useCase: "Use to confirm which Hevy account is connected before reading or writing account data.",
|
|
1971
|
+
importantNotes: "Accepts no inputs and reports only the account associated with the configured credentials."
|
|
1972
|
+
}),
|
|
1973
|
+
inputSchema: {},
|
|
1974
|
+
kind: "read",
|
|
1975
|
+
outputSchema: userResponse.outputSchema,
|
|
1976
|
+
annotations: readOnlyAnnotations("Get User Info"),
|
|
1977
|
+
responseContract: userResponse,
|
|
1978
|
+
execute: async (runtime) => {
|
|
1979
|
+
return (await runtime.getClient().getUserInfo())?.data;
|
|
1980
|
+
}
|
|
1981
|
+
}];
|
|
1982
|
+
//#endregion
|
|
1983
|
+
//#region src/tools/routine-discovery.ts
|
|
1984
|
+
const routineDiscoverySchema = {
|
|
1985
|
+
query: z.string().min(1).optional().describe("Optional case-insensitive substring to match routine titles."),
|
|
1986
|
+
limit: z.coerce.number().int().min(1).max(100).default(20).describe("Maximum compact routines to return (1-100).")
|
|
1987
|
+
};
|
|
1988
|
+
async function discoverRoutines(runtime, { query, limit }) {
|
|
1989
|
+
const normalizedQuery = query?.toLocaleLowerCase();
|
|
1990
|
+
const routines = [];
|
|
1991
|
+
let page = 1;
|
|
1992
|
+
let pages = 0;
|
|
1993
|
+
let itemsScanned = 0;
|
|
1994
|
+
const client = runtime.getClient();
|
|
1995
|
+
while (routines.length < limit) {
|
|
1996
|
+
const data = await client.getRoutines({
|
|
1997
|
+
page,
|
|
1998
|
+
pageSize: 10
|
|
1794
1999
|
});
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
return createStructuredJsonResponse(data.data, { user: data.data });
|
|
1824
|
-
}, "get-user-info"));
|
|
2000
|
+
pages = page;
|
|
2001
|
+
const pageItems = data?.routines ?? [];
|
|
2002
|
+
itemsScanned += pageItems.length;
|
|
2003
|
+
for (const routine of pageItems) {
|
|
2004
|
+
if (normalizedQuery && !routine.title?.toLocaleLowerCase().includes(normalizedQuery)) continue;
|
|
2005
|
+
routines.push(routine);
|
|
2006
|
+
if (routines.length >= limit) break;
|
|
2007
|
+
}
|
|
2008
|
+
const pageCount = data?.page_count;
|
|
2009
|
+
if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) break;
|
|
2010
|
+
page += 1;
|
|
2011
|
+
}
|
|
2012
|
+
return {
|
|
2013
|
+
routines: routines.slice(0, limit).map((routine) => ({
|
|
2014
|
+
...routine.id ? { id: routine.id } : {},
|
|
2015
|
+
...routine.title ? { title: routine.title } : {},
|
|
2016
|
+
folderId: routine.folder_id ?? null,
|
|
2017
|
+
...routine.updated_at ? { updatedAt: routine.updated_at } : {},
|
|
2018
|
+
exerciseCount: routine.exercises?.length ?? 0,
|
|
2019
|
+
setCount: routine.exercises?.reduce((total, exercise) => total + (exercise.sets?.length ?? 0), 0) ?? 0
|
|
2020
|
+
})),
|
|
2021
|
+
workflow: {
|
|
2022
|
+
name: "routine-discovery",
|
|
2023
|
+
pagination: { routines: pages },
|
|
2024
|
+
cacheStatus: "not-used",
|
|
2025
|
+
itemsScanned
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
1825
2028
|
}
|
|
2029
|
+
const routineDiscoveryToolDefinitions = [{
|
|
2030
|
+
name: "search-routines",
|
|
2031
|
+
description: describeTool({
|
|
2032
|
+
summary: "Read-only. Discovers routines by title and returns compact metadata without full set payloads.",
|
|
2033
|
+
aliases: [
|
|
2034
|
+
"find routine",
|
|
2035
|
+
"browse routine names",
|
|
2036
|
+
"compact routine list"
|
|
2037
|
+
],
|
|
2038
|
+
useCase: "Use to find a routine ID or shortlist plans before calling get-routine for full exercise details.",
|
|
2039
|
+
importantNotes: "Search scans routine pages at pageSize 10 and returns only IDs, titles, folder metadata, and exercise/set counts."
|
|
2040
|
+
}),
|
|
2041
|
+
inputSchema: routineDiscoverySchema,
|
|
2042
|
+
outputSchema: compactRoutinesResponse.outputSchema,
|
|
2043
|
+
annotations: readOnlyAnnotations("Search Routines"),
|
|
2044
|
+
kind: "read",
|
|
2045
|
+
responseContract: compactRoutinesResponse,
|
|
2046
|
+
execute: async (runtime, args) => discoverRoutines(runtime, args)
|
|
2047
|
+
}];
|
|
1826
2048
|
//#endregion
|
|
1827
|
-
//#region src/tools/
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2049
|
+
//#region src/tools/workflows.ts
|
|
2050
|
+
const trainingSummarySchema = { weeks: z.coerce.number().int().min(1).max(12).default(4).describe("Number of recent weeks to summarize (1-12).") };
|
|
2051
|
+
const MILLISECONDS_PER_DAY = 1440 * 60 * 1e3;
|
|
2052
|
+
function parseUtcDate(value) {
|
|
2053
|
+
const normalized = value.length === 10 ? `${value}T00:00:00.000Z` : value;
|
|
2054
|
+
const timestamp = Date.parse(normalized);
|
|
2055
|
+
return Number.isFinite(timestamp) ? timestamp : void 0;
|
|
2056
|
+
}
|
|
2057
|
+
async function fetchRecentPages(loader, pageSize, startDate, endDate, getDate) {
|
|
2058
|
+
const items = [];
|
|
2059
|
+
let page = 1;
|
|
2060
|
+
let itemsScanned = 0;
|
|
2061
|
+
const startTimestamp = parseUtcDate(startDate);
|
|
2062
|
+
const endTimestamp = parseUtcDate(endDate);
|
|
2063
|
+
if (startTimestamp === void 0 || endTimestamp === void 0) return {
|
|
2064
|
+
items,
|
|
2065
|
+
pages: 0,
|
|
2066
|
+
itemsScanned
|
|
1832
2067
|
};
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2068
|
+
const endExclusiveTimestamp = endTimestamp + MILLISECONDS_PER_DAY;
|
|
2069
|
+
while (true) {
|
|
2070
|
+
const result = await loader(page, pageSize);
|
|
2071
|
+
itemsScanned += result.items.length;
|
|
2072
|
+
if (result.items.length === 0) break;
|
|
2073
|
+
for (const item of result.items) {
|
|
2074
|
+
const date = getDate(item);
|
|
2075
|
+
const timestamp = date === void 0 ? void 0 : parseUtcDate(date);
|
|
2076
|
+
if (timestamp !== void 0 && timestamp >= startTimestamp && timestamp < endExclusiveTimestamp) items.push(item);
|
|
2077
|
+
}
|
|
2078
|
+
const lastDate = result.items.map(getDate).filter((date) => date !== void 0).at(-1);
|
|
2079
|
+
const lastTimestamp = lastDate === void 0 ? void 0 : parseUtcDate(lastDate);
|
|
2080
|
+
if (lastTimestamp !== void 0 && lastTimestamp < startTimestamp) break;
|
|
2081
|
+
const pageCount = result.pageCount;
|
|
2082
|
+
if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) break;
|
|
2083
|
+
page += 1;
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
items,
|
|
2087
|
+
pages: page,
|
|
2088
|
+
itemsScanned
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
function utcDateString(date) {
|
|
2092
|
+
return date.toISOString().slice(0, 10);
|
|
2093
|
+
}
|
|
2094
|
+
function getPeriod(weeks) {
|
|
2095
|
+
const end = /* @__PURE__ */ new Date();
|
|
2096
|
+
const start = new Date(end);
|
|
2097
|
+
start.setUTCDate(start.getUTCDate() - weeks * 7);
|
|
2098
|
+
return {
|
|
2099
|
+
startDate: utcDateString(start),
|
|
2100
|
+
endDate: utcDateString(end)
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
function durationSeconds(workout) {
|
|
2104
|
+
if (!workout.start_time || !workout.end_time) return null;
|
|
2105
|
+
const duration = new Date(workout.end_time).getTime() - new Date(workout.start_time).getTime();
|
|
2106
|
+
return Number.isFinite(duration) && duration >= 0 ? Math.floor(duration / 1e3) : null;
|
|
2107
|
+
}
|
|
2108
|
+
function compactSession(workout) {
|
|
2109
|
+
const exercises = workout.exercises ?? [];
|
|
2110
|
+
return {
|
|
2111
|
+
...workout.id ? { id: workout.id } : {},
|
|
2112
|
+
...workout.title ? { title: workout.title } : {},
|
|
2113
|
+
...workout.start_time ? { startTime: workout.start_time } : {},
|
|
2114
|
+
...workout.end_time ? { endTime: workout.end_time } : {},
|
|
2115
|
+
durationSeconds: durationSeconds(workout),
|
|
2116
|
+
exerciseCount: exercises.length,
|
|
2117
|
+
setCount: exercises.reduce((total, exercise) => total + (exercise.sets?.length ?? 0), 0)
|
|
1837
2118
|
};
|
|
1838
|
-
throw new Error(`Unsupported workout event type: ${event.type}`);
|
|
1839
2119
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
|
|
2120
|
+
function compactMeasurement(measurement) {
|
|
2121
|
+
return {
|
|
2122
|
+
date: measurement.date,
|
|
2123
|
+
weightKg: measurement.weight_kg ?? null,
|
|
2124
|
+
leanMassKg: measurement.lean_mass_kg ?? null,
|
|
2125
|
+
fatPercent: measurement.fat_percent ?? null
|
|
1847
2126
|
};
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
],
|
|
1856
|
-
useCase: "Use to browse or page through workout history; use get-workout when a workout ID is already known.",
|
|
1857
|
-
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
|
|
1858
|
-
}),
|
|
1859
|
-
inputSchema: getWorkoutsSchema,
|
|
1860
|
-
outputSchema: workoutsOutputSchema,
|
|
1861
|
-
annotations: readOnlyAnnotations("Get Workouts")
|
|
1862
|
-
}, wrapHandler(async (args) => {
|
|
1863
|
-
const client = requireClient(hevyClient);
|
|
1864
|
-
const { page, pageSize } = args;
|
|
1865
|
-
const workouts = (await client.getWorkouts({
|
|
2127
|
+
}
|
|
2128
|
+
async function getTrainingSummary(runtime, weeks) {
|
|
2129
|
+
const client = runtime.getClient();
|
|
2130
|
+
const period = getPeriod(weeks);
|
|
2131
|
+
const pageSize = 10;
|
|
2132
|
+
const [workoutPages, measurementPages] = await Promise.all([fetchRecentPages(async (page, pageSize) => {
|
|
2133
|
+
const data = await client.getWorkouts({
|
|
1866
2134
|
page,
|
|
1867
2135
|
pageSize
|
|
1868
|
-
})
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
summary: "Read-only. Retrieves complete details for one workout by its ID.",
|
|
1876
|
-
aliases: [
|
|
1877
|
-
"show workout",
|
|
1878
|
-
"fetch workout details",
|
|
1879
|
-
"open workout log"
|
|
1880
|
-
],
|
|
1881
|
-
useCase: "Use after get-workouts identifies the exact workout; do not use for browsing multiple workouts.",
|
|
1882
|
-
importantNotes: "Requires a workoutId discovered from a workout list, event, or prior create response."
|
|
1883
|
-
}),
|
|
1884
|
-
inputSchema: getWorkoutSchema,
|
|
1885
|
-
outputSchema: workoutOutputSchema,
|
|
1886
|
-
annotations: readOnlyAnnotations("Get Workout")
|
|
1887
|
-
}, wrapHandler(async (args) => {
|
|
1888
|
-
const client = requireClient(hevyClient);
|
|
1889
|
-
const { workoutId } = args;
|
|
1890
|
-
const data = await client.getWorkout(workoutId);
|
|
1891
|
-
if (!data) return createStructuredEmptyResponse(`Workout with ID ${workoutId} not found`, { workout: null });
|
|
1892
|
-
const workout = formatWorkout(data);
|
|
1893
|
-
return createStructuredJsonResponse(workout, { workout });
|
|
1894
|
-
}, "get-workout"));
|
|
1895
|
-
server.registerTool("get-workout-count", {
|
|
1896
|
-
description: describeTool({
|
|
1897
|
-
summary: "Read-only. Returns the total workout count for the account.",
|
|
1898
|
-
aliases: [
|
|
1899
|
-
"count workouts",
|
|
1900
|
-
"how many workouts",
|
|
1901
|
-
"workout total"
|
|
1902
|
-
],
|
|
1903
|
-
useCase: "Use for totals, statistics, or estimating pages; use get-workouts for actual workout records.",
|
|
1904
|
-
importantNotes: "Returns only a count and accepts no paging or date filters."
|
|
1905
|
-
}),
|
|
1906
|
-
inputSchema: {},
|
|
1907
|
-
outputSchema: workoutCountOutputSchema,
|
|
1908
|
-
annotations: readOnlyAnnotations("Get Workout Count")
|
|
1909
|
-
}, wrapHandler(async () => {
|
|
1910
|
-
const count = (await requireClient(hevyClient).getWorkoutCount())?.workout_count ?? 0;
|
|
1911
|
-
return createStructuredJsonResponse({ count }, { count });
|
|
1912
|
-
}, "get-workout-count"));
|
|
1913
|
-
const getWorkoutEventsSchema = {
|
|
1914
|
-
page: z.coerce.number().int().gte(1).default(1),
|
|
1915
|
-
pageSize: z.coerce.number().int().gte(1).lte(10).default(5),
|
|
1916
|
-
since: z.string().default("1970-01-01T00:00:00Z")
|
|
1917
|
-
};
|
|
1918
|
-
server.registerTool("get-workout-events", {
|
|
1919
|
-
description: describeTool({
|
|
1920
|
-
summary: "Read-only. Lists workout update and delete events since a timestamp, newest first.",
|
|
1921
|
-
aliases: [
|
|
1922
|
-
"sync workout changes",
|
|
1923
|
-
"workout change feed",
|
|
1924
|
-
"deleted workouts"
|
|
1925
|
-
],
|
|
1926
|
-
useCase: "Use to incrementally synchronize a local workout cache; use get-workouts for the current workout list.",
|
|
1927
|
-
importantNotes: "since must be a timestamp string; events are paginated with pageSize at most 10, and the default since value reads from 1970."
|
|
1928
|
-
}),
|
|
1929
|
-
inputSchema: getWorkoutEventsSchema,
|
|
1930
|
-
outputSchema: workoutEventsOutputSchema,
|
|
1931
|
-
annotations: readOnlyAnnotations("Get Workout Events")
|
|
1932
|
-
}, wrapHandler(async (args) => {
|
|
1933
|
-
const client = requireClient(hevyClient);
|
|
1934
|
-
const { page, pageSize, since } = args;
|
|
1935
|
-
const events = (await client.getWorkoutEvents({
|
|
2136
|
+
});
|
|
2137
|
+
return {
|
|
2138
|
+
items: data?.workouts ?? [],
|
|
2139
|
+
pageCount: data?.page_count
|
|
2140
|
+
};
|
|
2141
|
+
}, pageSize, period.startDate, period.endDate, (workout) => workout.start_time), fetchRecentPages(async (page, pageSize) => {
|
|
2142
|
+
const data = await client.getBodyMeasurements({
|
|
1936
2143
|
page,
|
|
1937
|
-
pageSize
|
|
1938
|
-
since
|
|
1939
|
-
}))?.events?.map(formatWorkoutEvent) || [];
|
|
1940
|
-
if (events.length === 0) return createStructuredEmptyResponse(`No workout events found for the specified parameters since ${since}`, { events: [] });
|
|
1941
|
-
return createStructuredJsonResponse(events, { events });
|
|
1942
|
-
}, "get-workout-events"));
|
|
1943
|
-
const createWorkoutSchema = {
|
|
1944
|
-
title: z.string().min(1),
|
|
1945
|
-
description: z.string().optional().nullable(),
|
|
1946
|
-
startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
|
|
1947
|
-
endTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
|
|
1948
|
-
isPrivate: z.boolean().default(false),
|
|
1949
|
-
exercises: z.preprocess(parseJsonArray, z.array(z.object({
|
|
1950
|
-
exerciseTemplateId: z.string().min(1),
|
|
1951
|
-
supersetId: z.coerce.number().nullable().optional(),
|
|
1952
|
-
notes: z.string().optional().nullable(),
|
|
1953
|
-
sets: z.array(z.object({
|
|
1954
|
-
type: setTypeEnum,
|
|
1955
|
-
weight: z.coerce.number().optional().nullable(),
|
|
1956
|
-
weightKg: z.coerce.number().optional().nullable(),
|
|
1957
|
-
reps: z.coerce.number().int().optional().nullable(),
|
|
1958
|
-
distance: z.coerce.number().int().optional().nullable(),
|
|
1959
|
-
distanceMeters: z.coerce.number().int().optional().nullable(),
|
|
1960
|
-
duration: z.coerce.number().int().optional().nullable(),
|
|
1961
|
-
durationSeconds: z.coerce.number().int().optional().nullable(),
|
|
1962
|
-
rpe: z.coerce.number().optional().nullable(),
|
|
1963
|
-
customMetric: z.coerce.number().optional().nullable()
|
|
1964
|
-
}))
|
|
1965
|
-
})))
|
|
1966
|
-
};
|
|
1967
|
-
server.tool("create-workout", describeTool({
|
|
1968
|
-
summary: "Writes to the Hevy account by creating a new workout.",
|
|
1969
|
-
aliases: [
|
|
1970
|
-
"log workout",
|
|
1971
|
-
"add workout",
|
|
1972
|
-
"record training session"
|
|
1973
|
-
],
|
|
1974
|
-
useCase: "Use to add a completed workout; use update-workout only when modifying an existing workout ID.",
|
|
1975
|
-
importantNotes: "Requires UTC startTime/endTime in YYYY-MM-DDTHH:mm:ssZ form and exercise template IDs. Retrying can create duplicates."
|
|
1976
|
-
}), createWorkoutSchema, createAnnotations("Create Workout"), wrapHandler(async (args) => {
|
|
1977
|
-
const client = requireClient(hevyClient);
|
|
1978
|
-
const { title, description, startTime, endTime, isPrivate, exercises } = args;
|
|
1979
|
-
const requestBody = { workout: {
|
|
1980
|
-
title,
|
|
1981
|
-
description: description ?? null,
|
|
1982
|
-
start_time: startTime,
|
|
1983
|
-
end_time: endTime,
|
|
1984
|
-
is_private: isPrivate,
|
|
1985
|
-
exercises: exercises.map((exercise) => ({
|
|
1986
|
-
exercise_template_id: exercise.exerciseTemplateId,
|
|
1987
|
-
superset_id: exercise.supersetId ?? null,
|
|
1988
|
-
notes: exercise.notes ?? null,
|
|
1989
|
-
sets: exercise.sets.map((set) => ({
|
|
1990
|
-
type: set.type,
|
|
1991
|
-
weight_kg: set.weight ?? set.weightKg ?? null,
|
|
1992
|
-
reps: set.reps ?? null,
|
|
1993
|
-
distance_meters: set.distance ?? set.distanceMeters ?? null,
|
|
1994
|
-
duration_seconds: set.duration ?? set.durationSeconds ?? null,
|
|
1995
|
-
rpe: set.rpe ?? null,
|
|
1996
|
-
custom_metric: set.customMetric ?? null
|
|
1997
|
-
}))
|
|
1998
|
-
}))
|
|
1999
|
-
} };
|
|
2000
|
-
const data = await client.createWorkout(requestBody);
|
|
2001
|
-
if (!data) return createEmptyResponse("Failed to create workout: Server returned no data");
|
|
2002
|
-
return createJsonResponse(formatWorkout(data), {
|
|
2003
|
-
pretty: true,
|
|
2004
|
-
indent: 2
|
|
2144
|
+
pageSize
|
|
2005
2145
|
});
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2146
|
+
return {
|
|
2147
|
+
items: data?.body_measurements ?? [],
|
|
2148
|
+
pageCount: data?.page_count
|
|
2149
|
+
};
|
|
2150
|
+
}, pageSize, period.startDate, period.endDate, (measurement) => measurement.date)]);
|
|
2151
|
+
const workouts = workoutPages.items;
|
|
2152
|
+
const sessions = workouts.map(compactSession);
|
|
2153
|
+
const uniqueExerciseTemplateIds = [...new Set(workouts.flatMap((workout) => (workout.exercises ?? []).map((exercise) => exercise.exercise_template_id).filter((id) => Boolean(id))))];
|
|
2154
|
+
const measurements = [...measurementPages.items].sort((a, b) => a.date.localeCompare(b.date));
|
|
2155
|
+
const earliestMeasurement = measurements[0];
|
|
2156
|
+
const latestMeasurement = measurements.at(-1);
|
|
2157
|
+
const earliest = earliestMeasurement ? compactMeasurement(earliestMeasurement) : null;
|
|
2158
|
+
const latest = latestMeasurement ? compactMeasurement(latestMeasurement) : null;
|
|
2159
|
+
const weightChangeKg = latest?.weightKg !== null && latest?.weightKg !== void 0 && earliest?.weightKg !== null && earliest?.weightKg !== void 0 ? latest.weightKg - earliest.weightKg : null;
|
|
2160
|
+
return {
|
|
2161
|
+
period: {
|
|
2162
|
+
...period,
|
|
2163
|
+
weeks
|
|
2164
|
+
},
|
|
2165
|
+
workouts: {
|
|
2166
|
+
count: workouts.length,
|
|
2167
|
+
totalDurationSeconds: sessions.reduce((total, session) => total + (session.durationSeconds ?? 0), 0),
|
|
2168
|
+
exerciseCount: sessions.reduce((total, session) => total + session.exerciseCount, 0),
|
|
2169
|
+
setCount: sessions.reduce((total, session) => total + session.setCount, 0),
|
|
2170
|
+
uniqueExerciseTemplateIds,
|
|
2171
|
+
sessions
|
|
2172
|
+
},
|
|
2173
|
+
bodyMeasurements: {
|
|
2174
|
+
count: measurements.length,
|
|
2175
|
+
latest,
|
|
2176
|
+
earliest,
|
|
2177
|
+
weightChangeKg
|
|
2178
|
+
},
|
|
2179
|
+
workflow: {
|
|
2180
|
+
name: "training-summary",
|
|
2181
|
+
pagination: {
|
|
2182
|
+
workouts: workoutPages.pages,
|
|
2183
|
+
bodyMeasurements: measurementPages.pages
|
|
2184
|
+
},
|
|
2185
|
+
cacheStatus: "not-used",
|
|
2186
|
+
itemsScanned: workoutPages.itemsScanned + measurementPages.itemsScanned
|
|
2187
|
+
}
|
|
2031
2188
|
};
|
|
2032
|
-
|
|
2033
|
-
|
|
2189
|
+
}
|
|
2190
|
+
const workflowToolDefinitions = [{
|
|
2191
|
+
name: "get-training-summary",
|
|
2192
|
+
description: describeTool({
|
|
2193
|
+
summary: "Read-only. Summarizes recent workout activity and body-measurement trends in one call.",
|
|
2034
2194
|
aliases: [
|
|
2035
|
-
"
|
|
2036
|
-
"
|
|
2037
|
-
"
|
|
2195
|
+
"training progress",
|
|
2196
|
+
"progress summary",
|
|
2197
|
+
"recent training overview"
|
|
2038
2198
|
],
|
|
2039
|
-
useCase: "Use
|
|
2040
|
-
importantNotes: "
|
|
2041
|
-
}),
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
indent: 2
|
|
2070
|
-
});
|
|
2071
|
-
}, "update-workout-operation"));
|
|
2072
|
-
}
|
|
2199
|
+
useCase: "Use for a bounded progress review instead of separately counting and paging through workouts and body measurements.",
|
|
2200
|
+
importantNotes: "The summary covers the most recent 1-12 weeks, returns compact session evidence, and reports the pages and items scanned."
|
|
2201
|
+
}),
|
|
2202
|
+
inputSchema: trainingSummarySchema,
|
|
2203
|
+
outputSchema: trainingSummaryResponse.outputSchema,
|
|
2204
|
+
annotations: readOnlyAnnotations("Get Training Summary"),
|
|
2205
|
+
kind: "read",
|
|
2206
|
+
responseContract: trainingSummaryResponse,
|
|
2207
|
+
execute: async (runtime, args) => getTrainingSummary(runtime, args.weeks)
|
|
2208
|
+
}];
|
|
2209
|
+
//#endregion
|
|
2210
|
+
//#region src/tools/workouts.ts
|
|
2211
|
+
const getWorkoutsSchema = paginationShape({
|
|
2212
|
+
defaultPageSize: 5,
|
|
2213
|
+
maxPageSize: 10,
|
|
2214
|
+
integerPage: false
|
|
2215
|
+
});
|
|
2216
|
+
const getWorkoutSchema = { workoutId: nonEmptyId };
|
|
2217
|
+
const getWorkoutEventsSchema = {
|
|
2218
|
+
...paginationShape({
|
|
2219
|
+
defaultPageSize: 5,
|
|
2220
|
+
maxPageSize: 10
|
|
2221
|
+
}),
|
|
2222
|
+
since: z.string().default("1970-01-01T00:00:00Z")
|
|
2223
|
+
};
|
|
2224
|
+
const createWorkoutSchema = workoutPayloadShape;
|
|
2225
|
+
const updateWorkoutSchema = {
|
|
2226
|
+
workoutId: nonEmptyId,
|
|
2227
|
+
...workoutPayloadShape
|
|
2228
|
+
};
|
|
2073
2229
|
//#endregion
|
|
2074
2230
|
//#region src/tools/register.ts
|
|
2231
|
+
const hevyToolDefinitions = [
|
|
2232
|
+
...[
|
|
2233
|
+
{
|
|
2234
|
+
name: "get-workouts",
|
|
2235
|
+
description: describeTool({
|
|
2236
|
+
summary: "Read-only. Lists workouts from newest to oldest with exercise and timing details.",
|
|
2237
|
+
aliases: [
|
|
2238
|
+
"list workout history",
|
|
2239
|
+
"show recent workouts",
|
|
2240
|
+
"browse logs"
|
|
2241
|
+
],
|
|
2242
|
+
useCase: "Use to browse or page through workout history; use get-workout when a workout ID is already known.",
|
|
2243
|
+
importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
|
|
2244
|
+
}),
|
|
2245
|
+
inputSchema: getWorkoutsSchema,
|
|
2246
|
+
outputSchema: workoutsResponse.outputSchema,
|
|
2247
|
+
annotations: readOnlyAnnotations("Get Workouts"),
|
|
2248
|
+
kind: "read",
|
|
2249
|
+
responseContract: workoutsResponse,
|
|
2250
|
+
execute: async (runtime, args) => {
|
|
2251
|
+
return (await runtime.getClient().getWorkouts({
|
|
2252
|
+
page: args.page,
|
|
2253
|
+
pageSize: args.pageSize
|
|
2254
|
+
}))?.workouts;
|
|
2255
|
+
}
|
|
2256
|
+
},
|
|
2257
|
+
{
|
|
2258
|
+
name: "get-workout",
|
|
2259
|
+
description: describeTool({
|
|
2260
|
+
summary: "Read-only. Retrieves complete details for one workout by its ID.",
|
|
2261
|
+
aliases: [
|
|
2262
|
+
"show workout",
|
|
2263
|
+
"fetch workout details",
|
|
2264
|
+
"open workout log"
|
|
2265
|
+
],
|
|
2266
|
+
useCase: "Use after get-workouts identifies the exact workout; do not use for browsing multiple workouts.",
|
|
2267
|
+
importantNotes: "Requires a workoutId discovered from a workout list, event, or prior create response."
|
|
2268
|
+
}),
|
|
2269
|
+
inputSchema: getWorkoutSchema,
|
|
2270
|
+
outputSchema: workoutResponse.outputSchema,
|
|
2271
|
+
annotations: readOnlyAnnotations("Get Workout"),
|
|
2272
|
+
kind: "read",
|
|
2273
|
+
responseContract: workoutResponse,
|
|
2274
|
+
execute: async (runtime, args) => {
|
|
2275
|
+
return {
|
|
2276
|
+
workout: await runtime.getClient().getWorkout(args.workoutId),
|
|
2277
|
+
workoutId: args.workoutId
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
},
|
|
2281
|
+
{
|
|
2282
|
+
name: "get-workout-count",
|
|
2283
|
+
description: describeTool({
|
|
2284
|
+
summary: "Read-only. Returns the total workout count for the account.",
|
|
2285
|
+
aliases: [
|
|
2286
|
+
"count workouts",
|
|
2287
|
+
"how many workouts",
|
|
2288
|
+
"workout total"
|
|
2289
|
+
],
|
|
2290
|
+
useCase: "Use for totals, statistics, or estimating pages; use get-workouts for actual workout records.",
|
|
2291
|
+
importantNotes: "Returns only a count and accepts no paging or date filters."
|
|
2292
|
+
}),
|
|
2293
|
+
inputSchema: {},
|
|
2294
|
+
outputSchema: workoutCountResponse.outputSchema,
|
|
2295
|
+
annotations: readOnlyAnnotations("Get Workout Count"),
|
|
2296
|
+
kind: "read",
|
|
2297
|
+
responseContract: workoutCountResponse,
|
|
2298
|
+
execute: async (runtime) => {
|
|
2299
|
+
return (await runtime.getClient().getWorkoutCount())?.workout_count ?? 0;
|
|
2300
|
+
}
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
name: "get-workout-events",
|
|
2304
|
+
description: describeTool({
|
|
2305
|
+
summary: "Read-only. Lists workout update and delete events since a timestamp, newest first.",
|
|
2306
|
+
aliases: [
|
|
2307
|
+
"sync workout changes",
|
|
2308
|
+
"workout change feed",
|
|
2309
|
+
"deleted workouts"
|
|
2310
|
+
],
|
|
2311
|
+
useCase: "Use to incrementally synchronize a local workout cache; use get-workouts for the current workout list.",
|
|
2312
|
+
importantNotes: "since must be a timestamp string; events are paginated with pageSize at most 10, and the default since value reads from 1970."
|
|
2313
|
+
}),
|
|
2314
|
+
inputSchema: getWorkoutEventsSchema,
|
|
2315
|
+
outputSchema: workoutEventsResponse.outputSchema,
|
|
2316
|
+
annotations: readOnlyAnnotations("Get Workout Events"),
|
|
2317
|
+
kind: "read",
|
|
2318
|
+
responseContract: workoutEventsResponse,
|
|
2319
|
+
execute: async (runtime, args) => {
|
|
2320
|
+
return {
|
|
2321
|
+
events: (await runtime.getClient().getWorkoutEvents({
|
|
2322
|
+
page: args.page,
|
|
2323
|
+
pageSize: args.pageSize,
|
|
2324
|
+
since: args.since
|
|
2325
|
+
}))?.events,
|
|
2326
|
+
since: args.since
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
{
|
|
2331
|
+
name: "create-workout",
|
|
2332
|
+
description: describeTool({
|
|
2333
|
+
summary: "Writes to the Hevy account by creating a new workout.",
|
|
2334
|
+
aliases: [
|
|
2335
|
+
"log workout",
|
|
2336
|
+
"add workout",
|
|
2337
|
+
"record training session"
|
|
2338
|
+
],
|
|
2339
|
+
useCase: "Use to add a completed workout; use update-workout only when modifying an existing workout ID.",
|
|
2340
|
+
importantNotes: "Requires UTC startTime/endTime in YYYY-MM-DDTHH:mm:ssZ form and exercise template IDs. Retrying can create duplicates."
|
|
2341
|
+
}),
|
|
2342
|
+
inputSchema: createWorkoutSchema,
|
|
2343
|
+
annotations: createAnnotations("Create Workout"),
|
|
2344
|
+
kind: "write",
|
|
2345
|
+
responseContract: createWorkoutResponse,
|
|
2346
|
+
execute: async (runtime, args) => {
|
|
2347
|
+
return await runtime.getClient().createWorkout({ workout: buildWorkoutPayload(args) });
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
name: "update-workout",
|
|
2352
|
+
description: describeTool({
|
|
2353
|
+
summary: "Mutates the Hevy account by replacing an existing workout.",
|
|
2354
|
+
aliases: [
|
|
2355
|
+
"edit workout",
|
|
2356
|
+
"correct workout log",
|
|
2357
|
+
"replace workout details"
|
|
2358
|
+
],
|
|
2359
|
+
useCase: "Use to revise a known workout; use create-workout for a new training session.",
|
|
2360
|
+
importantNotes: "Requires workoutId plus the complete title, times, privacy, exercises, and sets payload; omitted optional values may be cleared or defaulted."
|
|
2361
|
+
}),
|
|
2362
|
+
inputSchema: updateWorkoutSchema,
|
|
2363
|
+
annotations: updateAnnotations("Update Workout"),
|
|
2364
|
+
kind: "write",
|
|
2365
|
+
responseContract: updateWorkoutResponse,
|
|
2366
|
+
execute: async (runtime, args) => {
|
|
2367
|
+
return {
|
|
2368
|
+
workout: await runtime.getClient().updateWorkout(args.workoutId, { workout: buildWorkoutPayload(args) }),
|
|
2369
|
+
workoutId: args.workoutId
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
],
|
|
2374
|
+
...routineToolDefinitions,
|
|
2375
|
+
...templateToolDefinitions,
|
|
2376
|
+
...folderToolDefinitions,
|
|
2377
|
+
...bodyMeasurementToolDefinitions,
|
|
2378
|
+
...userToolDefinitions,
|
|
2379
|
+
...workflowToolDefinitions,
|
|
2380
|
+
...routineDiscoveryToolDefinitions
|
|
2381
|
+
];
|
|
2075
2382
|
/** Register every Hevy tool in its production ordering. */
|
|
2076
|
-
function registerHevyTools(server,
|
|
2077
|
-
const
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2383
|
+
function registerHevyTools(server, runtime) {
|
|
2384
|
+
for (const definition of hevyToolDefinitions) registerToolDefinition(server, runtime, definition);
|
|
2385
|
+
}
|
|
2386
|
+
//#endregion
|
|
2387
|
+
//#region src/utils/error-handler.ts
|
|
2388
|
+
/**
|
|
2389
|
+
* Centralized error handling utility for MCP tools
|
|
2390
|
+
*/
|
|
2391
|
+
/**
|
|
2392
|
+
* Create a standardized error response for MCP tools
|
|
2393
|
+
*
|
|
2394
|
+
* @param error - The error object or message
|
|
2395
|
+
* @param context - Optional context information about where the error occurred
|
|
2396
|
+
* @returns A formatted MCP tool response with error information
|
|
2397
|
+
*/
|
|
2398
|
+
function createErrorResponse(error, context) {
|
|
2399
|
+
const policy = resolveErrorPolicy(error, "The request failed unexpectedly. Please try again.", HEVY_CLIENT_NOT_INITIALIZED_ERROR);
|
|
2400
|
+
const { diagnostic } = policy;
|
|
2401
|
+
const axiosErrorContext = diagnostic.status !== void 0 || diagnostic.method !== void 0 || diagnostic.endpoint !== void 0 ? {
|
|
2402
|
+
status: diagnostic.status,
|
|
2403
|
+
method: diagnostic.method,
|
|
2404
|
+
url: diagnostic.endpoint
|
|
2405
|
+
} : null;
|
|
2406
|
+
const errorContext = {
|
|
2407
|
+
sourceContext: context,
|
|
2408
|
+
originalErrorMessage: `${diagnostic.category} occurred`,
|
|
2409
|
+
errorCode: diagnostic.code,
|
|
2410
|
+
errorType: policy.type,
|
|
2411
|
+
axios: axiosErrorContext ?? void 0
|
|
2412
|
+
};
|
|
2413
|
+
const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${policy.message}`;
|
|
2414
|
+
console.error("MCP tool failure", diagnostic);
|
|
2415
|
+
return {
|
|
2416
|
+
content: [{
|
|
2417
|
+
type: "text",
|
|
2418
|
+
text: formattedMessage
|
|
2419
|
+
}],
|
|
2420
|
+
isError: true,
|
|
2421
|
+
errorContext
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Wrap an async function with standardized error handling
|
|
2426
|
+
*
|
|
2427
|
+
* This function preserves the parameter types of the wrapped function while
|
|
2428
|
+
* providing error handling. The returned function accepts Record<string, unknown>
|
|
2429
|
+
* (as required by MCP SDK) but internally casts to the original parameter type.
|
|
2430
|
+
*
|
|
2431
|
+
* @param fn - The async function to wrap
|
|
2432
|
+
* @param context - Context information for error messages
|
|
2433
|
+
* @returns A function that catches errors and returns standardized error responses
|
|
2434
|
+
*/
|
|
2435
|
+
function withErrorHandling(fn, context, onError) {
|
|
2436
|
+
return async (rawArgs) => {
|
|
2437
|
+
const args = rawArgs ?? {};
|
|
2438
|
+
try {
|
|
2439
|
+
return await fn(args);
|
|
2440
|
+
} catch (error) {
|
|
2441
|
+
try {
|
|
2442
|
+
onError?.(error, context, Object.keys(args).length);
|
|
2443
|
+
} catch {
|
|
2444
|
+
console.error("MCP error observer failure", { category: "ObserverError" });
|
|
2445
|
+
}
|
|
2446
|
+
return createErrorResponse(error, context);
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
//#endregion
|
|
2451
|
+
//#region src/utils/cache.ts
|
|
2452
|
+
/**
|
|
2453
|
+
* Shared in-memory cache for async fetches with TTL, LRU eviction,
|
|
2454
|
+
* in-flight de-duplication, and explicit refresh/invalidation support.
|
|
2455
|
+
*/
|
|
2456
|
+
var AsyncTtlCache = class {
|
|
2457
|
+
ttlMs;
|
|
2458
|
+
maxSize;
|
|
2459
|
+
now;
|
|
2460
|
+
entries = /* @__PURE__ */ new Map();
|
|
2461
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2462
|
+
requestCounter = 0;
|
|
2463
|
+
constructor(options, now = Date.now) {
|
|
2464
|
+
const { ttlMs, maxSize } = options;
|
|
2465
|
+
if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
|
|
2466
|
+
if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
|
|
2467
|
+
this.ttlMs = ttlMs;
|
|
2468
|
+
this.maxSize = maxSize;
|
|
2469
|
+
this.now = now;
|
|
2470
|
+
}
|
|
2471
|
+
async getOrFetch(key, fetcher, options = {}) {
|
|
2472
|
+
const { refresh = false } = options;
|
|
2473
|
+
if (refresh) this.invalidate(key);
|
|
2474
|
+
else {
|
|
2475
|
+
const cachedEntry = this.entries.get(key);
|
|
2476
|
+
if (cachedEntry !== void 0) {
|
|
2477
|
+
if (cachedEntry.expiresAt > this.now()) {
|
|
2478
|
+
this.markAsRecentlyUsed(key, cachedEntry);
|
|
2479
|
+
return cachedEntry.value;
|
|
2480
|
+
}
|
|
2481
|
+
this.entries.delete(key);
|
|
2482
|
+
}
|
|
2483
|
+
const inFlightEntry = this.inFlight.get(key);
|
|
2484
|
+
if (inFlightEntry !== void 0) return inFlightEntry.promise;
|
|
2485
|
+
}
|
|
2486
|
+
const requestId = ++this.requestCounter;
|
|
2487
|
+
const request = (async () => {
|
|
2488
|
+
try {
|
|
2489
|
+
const value = await fetcher();
|
|
2490
|
+
if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
|
|
2491
|
+
return value;
|
|
2492
|
+
} finally {
|
|
2493
|
+
if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
|
|
2494
|
+
}
|
|
2495
|
+
})();
|
|
2496
|
+
this.inFlight.set(key, {
|
|
2497
|
+
promise: request,
|
|
2498
|
+
requestId
|
|
2499
|
+
});
|
|
2500
|
+
return request;
|
|
2501
|
+
}
|
|
2502
|
+
invalidate(key) {
|
|
2503
|
+
this.entries.delete(key);
|
|
2504
|
+
this.inFlight.delete(key);
|
|
2505
|
+
}
|
|
2506
|
+
clear() {
|
|
2507
|
+
this.entries.clear();
|
|
2508
|
+
this.inFlight.clear();
|
|
2509
|
+
}
|
|
2510
|
+
get size() {
|
|
2511
|
+
return this.entries.size;
|
|
2512
|
+
}
|
|
2513
|
+
isCurrentRequest(key, requestId) {
|
|
2514
|
+
return this.inFlight.get(key)?.requestId === requestId;
|
|
2515
|
+
}
|
|
2516
|
+
markAsRecentlyUsed(key, entry) {
|
|
2517
|
+
this.entries.delete(key);
|
|
2518
|
+
this.entries.set(key, entry);
|
|
2519
|
+
}
|
|
2520
|
+
setValue(key, value) {
|
|
2521
|
+
this.entries.delete(key);
|
|
2522
|
+
this.entries.set(key, {
|
|
2523
|
+
value,
|
|
2524
|
+
expiresAt: this.now() + this.ttlMs
|
|
2525
|
+
});
|
|
2526
|
+
this.evictLeastRecentlyUsed();
|
|
2527
|
+
}
|
|
2528
|
+
evictLeastRecentlyUsed() {
|
|
2529
|
+
while (this.entries.size > this.maxSize) {
|
|
2530
|
+
const oldestKey = this.entries.keys().next().value;
|
|
2531
|
+
if (oldestKey === void 0) return;
|
|
2532
|
+
this.entries.delete(oldestKey);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
//#endregion
|
|
2537
|
+
//#region src/utils/exercise-template-catalog.ts
|
|
2538
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
|
|
2539
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS = 300 * 1e3;
|
|
2540
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE = 1;
|
|
2541
|
+
/** Create a cache owned by one MCP server/request lifecycle. */
|
|
2542
|
+
function createExerciseTemplateCatalog(hevyClient) {
|
|
2543
|
+
const cache = new AsyncTtlCache({
|
|
2544
|
+
ttlMs: EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS,
|
|
2545
|
+
maxSize: EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE
|
|
2084
2546
|
});
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2547
|
+
return {
|
|
2548
|
+
get(options = {}) {
|
|
2549
|
+
const reason = options.refresh ? "explicit-refresh" : cache.size === 0 ? "initial-load" : "ttl-expired";
|
|
2550
|
+
return cache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, async () => {
|
|
2551
|
+
const catalog = await fetchAllPages(async (page, pageSize) => {
|
|
2552
|
+
const data = await hevyClient.getExerciseTemplates({
|
|
2553
|
+
page,
|
|
2554
|
+
pageSize
|
|
2555
|
+
});
|
|
2556
|
+
return {
|
|
2557
|
+
items: data?.exercise_templates ?? [],
|
|
2558
|
+
pageCount: data?.page_count
|
|
2559
|
+
};
|
|
2560
|
+
}, 100);
|
|
2561
|
+
options.onRefreshed?.(catalog, reason);
|
|
2562
|
+
return catalog;
|
|
2563
|
+
}, options);
|
|
2564
|
+
},
|
|
2565
|
+
reset() {
|
|
2566
|
+
cache.clear();
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2088
2569
|
}
|
|
2089
2570
|
//#endregion
|
|
2090
2571
|
//#region src/generated/.kubb/fetch.ts
|
|
@@ -2709,9 +3190,6 @@ const SAFE_DYNAMIC_ENDPOINTS = [
|
|
|
2709
3190
|
["/v1/routines/", "/v1/routines/:routineId"],
|
|
2710
3191
|
["/v1/workouts/", "/v1/workouts/:workoutId"]
|
|
2711
3192
|
];
|
|
2712
|
-
function wrapApi(fn) {
|
|
2713
|
-
return fn;
|
|
2714
|
-
}
|
|
2715
3193
|
function normalizePositiveInteger(value, fallback) {
|
|
2716
3194
|
return value === void 0 || !Number.isFinite(value) || value <= 0 ? fallback : Math.max(1, Math.floor(value));
|
|
2717
3195
|
}
|
|
@@ -2910,28 +3388,28 @@ function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com", options = {
|
|
|
2910
3388
|
const headers = { "api-key": apiKey };
|
|
2911
3389
|
const client = createNativeClient(apiKey, baseUrl, options);
|
|
2912
3390
|
return {
|
|
2913
|
-
getWorkouts: (params) =>
|
|
2914
|
-
getWorkout: (workoutId) =>
|
|
2915
|
-
createWorkout: (data) =>
|
|
2916
|
-
updateWorkout: (workoutId, data) =>
|
|
2917
|
-
getWorkoutCount: () =>
|
|
2918
|
-
getWorkoutEvents: (params) =>
|
|
2919
|
-
getRoutines: (params) =>
|
|
2920
|
-
getRoutineById: (routineId) =>
|
|
2921
|
-
createRoutine: (data) =>
|
|
2922
|
-
updateRoutine: (routineId, data) =>
|
|
2923
|
-
getExerciseTemplates: (params) =>
|
|
2924
|
-
getExerciseTemplate: (templateId) =>
|
|
2925
|
-
getExerciseHistory: (exerciseTemplateId, params) =>
|
|
2926
|
-
createExerciseTemplate: (data) =>
|
|
2927
|
-
getRoutineFolders: (params) =>
|
|
2928
|
-
createRoutineFolder: (data) =>
|
|
2929
|
-
getRoutineFolder: (folderId) =>
|
|
2930
|
-
getBodyMeasurements: (params) =>
|
|
2931
|
-
getBodyMeasurement: (date) =>
|
|
2932
|
-
createBodyMeasurement: (data) =>
|
|
2933
|
-
updateBodyMeasurement: (date, data) =>
|
|
2934
|
-
getUserInfo: (config = {}) =>
|
|
3391
|
+
getWorkouts: (params) => getV1Workouts(headers, params, { client }),
|
|
3392
|
+
getWorkout: (workoutId) => getV1WorkoutsWorkoutid(workoutId, headers, { client }),
|
|
3393
|
+
createWorkout: (data) => postV1Workouts(data, headers, { client }),
|
|
3394
|
+
updateWorkout: (workoutId, data) => putV1WorkoutsWorkoutid(workoutId, data, headers, { client }),
|
|
3395
|
+
getWorkoutCount: () => getV1WorkoutsCount(headers, { client }),
|
|
3396
|
+
getWorkoutEvents: (params) => getV1WorkoutsEvents(headers, params, { client }),
|
|
3397
|
+
getRoutines: (params) => getV1Routines(headers, params, { client }),
|
|
3398
|
+
getRoutineById: (routineId) => getV1RoutinesRoutineid(routineId, headers, { client }),
|
|
3399
|
+
createRoutine: (data) => postV1Routines(data, headers, { client }),
|
|
3400
|
+
updateRoutine: (routineId, data) => putV1RoutinesRoutineid(routineId, data, headers, { client }),
|
|
3401
|
+
getExerciseTemplates: (params) => getV1ExerciseTemplates(headers, params, { client }),
|
|
3402
|
+
getExerciseTemplate: (templateId) => getV1ExerciseTemplatesExercisetemplateid(templateId, headers, { client }),
|
|
3403
|
+
getExerciseHistory: (exerciseTemplateId, params) => getV1ExerciseHistoryExercisetemplateid(exerciseTemplateId, headers, params, { client }),
|
|
3404
|
+
createExerciseTemplate: (data) => postV1ExerciseTemplates(data, headers, { client }),
|
|
3405
|
+
getRoutineFolders: (params) => getV1RoutineFolders(headers, params, { client }),
|
|
3406
|
+
createRoutineFolder: (data) => postV1RoutineFolders(data, headers, { client }),
|
|
3407
|
+
getRoutineFolder: (folderId) => getV1RoutineFoldersFolderid(folderId, headers, { client }),
|
|
3408
|
+
getBodyMeasurements: (params) => getV1BodyMeasurements(headers, params, { client }),
|
|
3409
|
+
getBodyMeasurement: (date) => getV1BodyMeasurementsDate(date, headers, { client }),
|
|
3410
|
+
createBodyMeasurement: (data) => postV1BodyMeasurements(data, headers, { client }),
|
|
3411
|
+
updateBodyMeasurement: (date, data) => putV1BodyMeasurementsDate(date, data, headers, { client }),
|
|
3412
|
+
getUserInfo: (config = {}) => getV1UserInfo(headers, {
|
|
2935
3413
|
...config,
|
|
2936
3414
|
client
|
|
2937
3415
|
})
|
|
@@ -2966,6 +3444,17 @@ function createMcpClientLogger(server) {
|
|
|
2966
3444
|
};
|
|
2967
3445
|
}
|
|
2968
3446
|
//#endregion
|
|
3447
|
+
//#region src/tools/tool-runtime.ts
|
|
3448
|
+
function createToolRuntime({ client, catalog, logger, wrapHandler = withErrorHandling }) {
|
|
3449
|
+
return {
|
|
3450
|
+
client,
|
|
3451
|
+
catalog,
|
|
3452
|
+
logger,
|
|
3453
|
+
wrapHandler,
|
|
3454
|
+
getClient: () => requireClient(client)
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
//#endregion
|
|
2969
3458
|
//#region src/shared-server.ts
|
|
2970
3459
|
function createToolCountingServer(server) {
|
|
2971
3460
|
let count = 0;
|
|
@@ -3004,17 +3493,17 @@ function createSharedMcpServer(options) {
|
|
|
3004
3493
|
...options.clientOptions,
|
|
3005
3494
|
logger
|
|
3006
3495
|
});
|
|
3007
|
-
const
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
registerHevyTools(counting.server, hevyClient, {
|
|
3011
|
-
catalog,
|
|
3496
|
+
const runtime = createToolRuntime({
|
|
3497
|
+
client: hevyClient,
|
|
3498
|
+
catalog: createExerciseTemplateCatalog(hevyClient),
|
|
3012
3499
|
logger,
|
|
3013
|
-
wrapHandler
|
|
3500
|
+
wrapHandler: options.wrapHandler ?? withErrorHandling
|
|
3014
3501
|
});
|
|
3502
|
+
const counting = createToolCountingServer(server);
|
|
3503
|
+
registerHevyTools(counting.server, runtime);
|
|
3015
3504
|
options.onToolsRegistered?.(counting.getCount());
|
|
3016
3505
|
registerWorkoutPrompts(server);
|
|
3017
|
-
registerHevyResources(server,
|
|
3506
|
+
registerHevyResources(server, runtime);
|
|
3018
3507
|
return server;
|
|
3019
3508
|
}
|
|
3020
3509
|
//#endregion
|
|
@@ -3264,6 +3753,28 @@ function extractSafeArgs(args) {
|
|
|
3264
3753
|
}
|
|
3265
3754
|
return attributes;
|
|
3266
3755
|
}
|
|
3756
|
+
function isRecord(value) {
|
|
3757
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3758
|
+
}
|
|
3759
|
+
function getWorkflowTelemetry(result) {
|
|
3760
|
+
const structuredContent = result.structuredContent;
|
|
3761
|
+
if (!isRecord(structuredContent)) return null;
|
|
3762
|
+
const workflow = structuredContent.workflow;
|
|
3763
|
+
if (!isRecord(workflow)) return null;
|
|
3764
|
+
const name = workflow.name;
|
|
3765
|
+
const paginationValue = workflow.pagination;
|
|
3766
|
+
const cacheStatus = workflow.cacheStatus;
|
|
3767
|
+
const itemsScanned = workflow.itemsScanned;
|
|
3768
|
+
if (typeof name !== "string" || !isRecord(paginationValue) || typeof cacheStatus !== "string" || typeof itemsScanned !== "number" || !Number.isSafeInteger(itemsScanned) || itemsScanned < 0) return null;
|
|
3769
|
+
const pagination = {};
|
|
3770
|
+
for (const [key, value] of Object.entries(paginationValue)) if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) pagination[key] = value;
|
|
3771
|
+
return {
|
|
3772
|
+
name,
|
|
3773
|
+
pagination,
|
|
3774
|
+
cacheStatus,
|
|
3775
|
+
itemsScanned
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3267
3778
|
/**
|
|
3268
3779
|
* Wrap an MCP tool handler with its existing OpenTelemetry span and metrics.
|
|
3269
3780
|
*/
|
|
@@ -3283,6 +3794,7 @@ function withTelemetry(fn, context) {
|
|
|
3283
3794
|
const whitelistedKeys = Object.keys(safeArgs).map((key) => key.replace("mcp.tool.args.", ""));
|
|
3284
3795
|
return tracer.startActiveSpan(`mcp.tool.${context}`, { attributes: {
|
|
3285
3796
|
"mcp.tool.name": context,
|
|
3797
|
+
"workflow.name": context,
|
|
3286
3798
|
"mcp.tool.args.key_count": argumentKeyCount,
|
|
3287
3799
|
"mcp.tool.args.keys": whitelistedKeys.join(","),
|
|
3288
3800
|
...userId ? { "user.id": userId } : {},
|
|
@@ -3298,11 +3810,19 @@ function withTelemetry(fn, context) {
|
|
|
3298
3810
|
const textLength = result.content.reduce((sum, item) => sum + (item.text?.length ?? 0), 0);
|
|
3299
3811
|
span.setAttribute("mcp.tool.result.text_length", textLength);
|
|
3300
3812
|
}
|
|
3813
|
+
const workflow = getWorkflowTelemetry(result);
|
|
3814
|
+
if (workflow) {
|
|
3815
|
+
span.setAttribute("workflow.name", workflow.name);
|
|
3816
|
+
span.setAttribute("workflow.cache_status", workflow.cacheStatus);
|
|
3817
|
+
span.setAttribute("workflow.items_scanned", workflow.itemsScanned);
|
|
3818
|
+
for (const [resource, pageCount] of Object.entries(workflow.pagination)) span.setAttribute(`workflow.pagination.${resource}.pages`, pageCount);
|
|
3819
|
+
}
|
|
3301
3820
|
return result;
|
|
3302
3821
|
} catch (error) {
|
|
3303
3822
|
isError = true;
|
|
3304
3823
|
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
3305
|
-
const
|
|
3824
|
+
const policy = resolveErrorPolicy(error, "");
|
|
3825
|
+
const { diagnostic } = policy;
|
|
3306
3826
|
span.addEvent("mcp.tool.failure", {
|
|
3307
3827
|
"error.category": diagnostic.category,
|
|
3308
3828
|
...diagnostic.code ? { "error.code": diagnostic.code } : {},
|
|
@@ -3310,7 +3830,7 @@ function withTelemetry(fn, context) {
|
|
|
3310
3830
|
...diagnostic.method ? { "http.method": diagnostic.method } : {},
|
|
3311
3831
|
...diagnostic.endpoint ? { "hevy.api.endpoint": diagnostic.endpoint } : {}
|
|
3312
3832
|
});
|
|
3313
|
-
const errorType =
|
|
3833
|
+
const errorType = policy.type;
|
|
3314
3834
|
span.setAttribute("error.type", errorType);
|
|
3315
3835
|
toolErrors.add(1, {
|
|
3316
3836
|
tool_name: context,
|
|
@@ -3334,7 +3854,7 @@ function withTelemetry(fn, context) {
|
|
|
3334
3854
|
*/
|
|
3335
3855
|
function withObservability(fn, context) {
|
|
3336
3856
|
return withErrorHandling(withTelemetry(fn, context), context, (error, _toolContext, argumentKeyCount) => {
|
|
3337
|
-
const diagnostic =
|
|
3857
|
+
const { diagnostic } = resolveErrorPolicy(error, "");
|
|
3338
3858
|
Sentry.withScope((scope) => {
|
|
3339
3859
|
scope.setTag("error.category", diagnostic.category);
|
|
3340
3860
|
if (diagnostic.code) scope.setTag("error.code", diagnostic.code);
|
|
@@ -3810,4 +4330,4 @@ async function runServer() {
|
|
|
3810
4330
|
//#endregion
|
|
3811
4331
|
export { createSafeErrorDiagnostic as i, createServer as n, runServer as r, configSchema as t };
|
|
3812
4332
|
|
|
3813
|
-
//# sourceMappingURL=src-
|
|
4333
|
+
//# sourceMappingURL=src-D1O3O9f2.mjs.map
|