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