hevy-mcp 2.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -343
- package/dist/cli.mjs +4 -4
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +1 -2
- package/dist/index.mjs +2 -2
- package/dist/{src-c3mB-R0m.mjs → src-CkkrTrrg.mjs} +899 -845
- package/dist/src-CkkrTrrg.mjs.map +1 -0
- package/package.json +23 -5
- package/server.json +2 -2
- package/dist/src-c3mB-R0m.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@
|
|
7
|
+
e.SENTRY_RELEASE = { id: "hevy-mcp@3.1.1" };
|
|
8
8
|
var n = new e.Error().stack;
|
|
9
|
-
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "
|
|
9
|
+
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "c68eeb18-d351-49fe-9f67-3f26fa89bfdb", e._sentryDebugIdIdentifier = "sentry-dbid-c68eeb18-d351-49fe-9f67-3f26fa89bfdb");
|
|
10
10
|
} catch (e) {}
|
|
11
11
|
})();
|
|
12
12
|
import * as Sentry from "@sentry/node";
|
|
@@ -19,10 +19,9 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
|
19
19
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
20
20
|
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
21
21
|
import { createHmac, randomUUID } from "node:crypto";
|
|
22
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24
23
|
import { z } from "zod";
|
|
25
|
-
import
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
25
|
import { z as z$1 } from "zod/v4";
|
|
27
26
|
import { deserializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
28
27
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
@@ -41,10 +40,10 @@ import semver from "semver";
|
|
|
41
40
|
* OTel Collector → Honeycomb: performance traces, metrics
|
|
42
41
|
*/
|
|
43
42
|
const name$1 = "hevy-mcp";
|
|
44
|
-
const version$1 = "
|
|
43
|
+
const version$1 = "3.1.1";
|
|
45
44
|
const collectorToken = "NH9vOela-HYreQxAbJa68cjEmORoEKM57EvneUDVcOo";
|
|
46
45
|
const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
|
|
47
|
-
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@
|
|
46
|
+
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@3.1.1`;
|
|
48
47
|
const resource = resourceFromAttributes({
|
|
49
48
|
"service.name": name$1,
|
|
50
49
|
"service.version": version$1
|
|
@@ -262,10 +261,8 @@ var AsyncTtlCache = class {
|
|
|
262
261
|
//#endregion
|
|
263
262
|
//#region src/utils/exercise-template-catalog.ts
|
|
264
263
|
const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
maxSize: 1
|
|
268
|
-
});
|
|
264
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS = 300 * 1e3;
|
|
265
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE = 1;
|
|
269
266
|
function getSafePageCount$1(data, currentPage) {
|
|
270
267
|
const pageCount = data?.page_count;
|
|
271
268
|
if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount < currentPage) return currentPage;
|
|
@@ -286,13 +283,179 @@ async function fetchExerciseTemplateCatalog(hevyClient) {
|
|
|
286
283
|
} while (page <= pageCount);
|
|
287
284
|
return allTemplates;
|
|
288
285
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
};
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/utils/hevy-http-error.ts
|
|
308
|
+
const HEVY_RETRY_EXHAUSTED_ERROR_CODE = "HEVY_RETRY_EXHAUSTED";
|
|
309
|
+
const HEVY_REQUEST_ABORTED_ERROR_CODE = "HEVY_REQUEST_ABORTED";
|
|
310
|
+
/** Sanitized HTTP error that never contains credentials or full request URLs. */
|
|
311
|
+
var HevyHttpError = class extends Error {
|
|
312
|
+
status;
|
|
313
|
+
statusText;
|
|
314
|
+
data;
|
|
315
|
+
headers;
|
|
316
|
+
method;
|
|
317
|
+
endpoint;
|
|
318
|
+
code;
|
|
319
|
+
hevyRetryCount;
|
|
320
|
+
hevyRetryExhausted;
|
|
321
|
+
constructor(message, options) {
|
|
322
|
+
super(message, { cause: options.cause });
|
|
323
|
+
this.name = "HevyHttpError";
|
|
324
|
+
this.status = options.status;
|
|
325
|
+
this.statusText = options.statusText;
|
|
326
|
+
this.data = options.data;
|
|
327
|
+
this.headers = options.headers;
|
|
328
|
+
this.method = options.method;
|
|
329
|
+
this.endpoint = options.endpoint;
|
|
330
|
+
this.code = options.code;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
function isHevyHttpError(error) {
|
|
334
|
+
return error instanceof HevyHttpError;
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/utils/safe-error-diagnostic.ts
|
|
338
|
+
const SAFE_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
339
|
+
"EAI_AGAIN",
|
|
340
|
+
"ECONNABORTED",
|
|
341
|
+
"ECONNREFUSED",
|
|
342
|
+
"ECONNRESET",
|
|
343
|
+
"ENETUNREACH",
|
|
344
|
+
"ENOTFOUND",
|
|
345
|
+
"ERR_NETWORK",
|
|
346
|
+
"ERR_SOCKET_TIMEOUT",
|
|
347
|
+
"ETIMEDOUT",
|
|
348
|
+
"HEVY_INVALID_ENDPOINT",
|
|
349
|
+
HEVY_REQUEST_ABORTED_ERROR_CODE,
|
|
350
|
+
HEVY_RETRY_EXHAUSTED_ERROR_CODE
|
|
351
|
+
]);
|
|
352
|
+
const SAFE_HTTP_METHODS = /* @__PURE__ */ new Set([
|
|
353
|
+
"DELETE",
|
|
354
|
+
"GET",
|
|
355
|
+
"HEAD",
|
|
356
|
+
"OPTIONS",
|
|
357
|
+
"PATCH",
|
|
358
|
+
"POST",
|
|
359
|
+
"PUT"
|
|
360
|
+
]);
|
|
361
|
+
const SAFE_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
362
|
+
"unknown",
|
|
363
|
+
"/v1/body_measurements",
|
|
364
|
+
"/v1/body_measurements/:date",
|
|
365
|
+
"/v1/exercise_history/:exerciseTemplateId",
|
|
366
|
+
"/v1/exercise_templates",
|
|
367
|
+
"/v1/exercise_templates/:exerciseTemplateId",
|
|
368
|
+
"/v1/routine_folders",
|
|
369
|
+
"/v1/routine_folders/:folderId",
|
|
370
|
+
"/v1/routines",
|
|
371
|
+
"/v1/routines/:routineId",
|
|
372
|
+
"/v1/user/info",
|
|
373
|
+
"/v1/workouts",
|
|
374
|
+
"/v1/workouts/:workoutId",
|
|
375
|
+
"/v1/workouts/count",
|
|
376
|
+
"/v1/workouts/events"
|
|
377
|
+
]);
|
|
378
|
+
const SAFE_SOURCE_SUFFIXES = [
|
|
379
|
+
["/src/utils/error-handler.ts", "error-handler"],
|
|
380
|
+
["/src/utils/hevyClientKubb.ts", "hevy-client"],
|
|
381
|
+
["/src/index.ts", "index"],
|
|
382
|
+
["/src/utils/observability-wrapper.ts", "observability-wrapper"],
|
|
383
|
+
["/src/shared-server.ts", "shared-server"],
|
|
384
|
+
["/src/worker.ts", "worker"]
|
|
385
|
+
];
|
|
386
|
+
const PROJECT_PATH_MARKER = "/hevy-mcp/";
|
|
387
|
+
const MAX_STACK_POSITION = 1e6;
|
|
388
|
+
function classifyError(error) {
|
|
389
|
+
if (isHevyHttpError(error)) return "HevyHttpError";
|
|
390
|
+
if (error instanceof TypeError) return "TypeError";
|
|
391
|
+
if (error instanceof RangeError) return "RangeError";
|
|
392
|
+
if (error instanceof ReferenceError) return "ReferenceError";
|
|
393
|
+
if (error instanceof SyntaxError) return "SyntaxError";
|
|
394
|
+
if (error instanceof URIError) return "URIError";
|
|
395
|
+
if (error instanceof EvalError) return "EvalError";
|
|
396
|
+
if (error instanceof AggregateError) return "AggregateError";
|
|
397
|
+
if (typeof DOMException !== "undefined" && error instanceof DOMException) return "DOMException";
|
|
398
|
+
if (error instanceof Error) return "Error";
|
|
399
|
+
return "UnknownError";
|
|
400
|
+
}
|
|
401
|
+
function getSafeCode(error) {
|
|
402
|
+
if (!error || typeof error !== "object" || !("code" in error)) return;
|
|
403
|
+
const code = error.code;
|
|
404
|
+
return typeof code === "string" && SAFE_ERROR_CODES.has(code) ? code : void 0;
|
|
405
|
+
}
|
|
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
|
+
function getSafeMethod(error) {
|
|
411
|
+
if (!isHevyHttpError(error)) return void 0;
|
|
412
|
+
const method = error.method.toUpperCase();
|
|
413
|
+
return SAFE_HTTP_METHODS.has(method) ? method : void 0;
|
|
414
|
+
}
|
|
415
|
+
function getSafeEndpoint(error) {
|
|
416
|
+
if (!isHevyHttpError(error)) return void 0;
|
|
417
|
+
return SAFE_ENDPOINTS.has(error.endpoint) ? error.endpoint : void 0;
|
|
418
|
+
}
|
|
419
|
+
function parseSafeStackFrames(error) {
|
|
420
|
+
if (!(error instanceof Error) || typeof error.stack !== "string") return;
|
|
421
|
+
const frames = [];
|
|
422
|
+
for (const frameLine of error.stack.split("\n").slice(1)) {
|
|
423
|
+
const match = /^\s{4}at (?:[^()\r\n]+ \()?([^()\s\r\n]+):(\d+):(\d+)\)?$/.exec(frameLine);
|
|
424
|
+
if (!match) continue;
|
|
425
|
+
const [, rawSource, rawLine, rawColumn] = match;
|
|
426
|
+
if (!rawSource || !rawLine || !rawColumn) continue;
|
|
427
|
+
if (rawSource.includes("?") || rawSource.includes("#") || !rawSource.startsWith("/") && !rawSource.startsWith("file:///") || !rawSource.includes(PROJECT_PATH_MARKER)) continue;
|
|
428
|
+
const source = SAFE_SOURCE_SUFFIXES.find(([suffix]) => rawSource.endsWith(suffix))?.[1];
|
|
429
|
+
if (!source) continue;
|
|
430
|
+
const line = Number(rawLine);
|
|
431
|
+
const column = Number(rawColumn);
|
|
432
|
+
if (!Number.isSafeInteger(line) || !Number.isSafeInteger(column) || line < 1 || column < 1 || line > MAX_STACK_POSITION || column > MAX_STACK_POSITION) continue;
|
|
433
|
+
frames.push({
|
|
434
|
+
source,
|
|
435
|
+
line,
|
|
436
|
+
column
|
|
437
|
+
});
|
|
438
|
+
if (frames.length === 3) break;
|
|
439
|
+
}
|
|
440
|
+
return frames.length > 0 ? frames : void 0;
|
|
441
|
+
}
|
|
442
|
+
function createSafeErrorDiagnostic(error) {
|
|
443
|
+
try {
|
|
444
|
+
const diagnostic = { category: classifyError(error) };
|
|
445
|
+
const code = getSafeCode(error);
|
|
446
|
+
const status = getSafeStatus(error);
|
|
447
|
+
const method = getSafeMethod(error);
|
|
448
|
+
const endpoint = getSafeEndpoint(error);
|
|
449
|
+
const frames = parseSafeStackFrames(error);
|
|
450
|
+
if (code) diagnostic.code = code;
|
|
451
|
+
if (status !== void 0) diagnostic.status = status;
|
|
452
|
+
if (method) diagnostic.method = method;
|
|
453
|
+
if (endpoint) diagnostic.endpoint = endpoint;
|
|
454
|
+
if (frames) diagnostic.frames = frames;
|
|
455
|
+
return diagnostic;
|
|
456
|
+
} catch {
|
|
457
|
+
return { category: "UnknownError" };
|
|
458
|
+
}
|
|
296
459
|
}
|
|
297
460
|
//#endregion
|
|
298
461
|
//#region src/utils/formatters.ts
|
|
@@ -405,7 +568,7 @@ function calculateDuration(startTime, endTime) {
|
|
|
405
568
|
if (durationMs < 0) return "Invalid duration (end time before start time)";
|
|
406
569
|
return `${Math.floor(durationMs / (1e3 * 60 * 60))}h ${Math.floor(durationMs % (1e3 * 60 * 60) / (1e3 * 60))}m ${Math.floor(durationMs % (1e3 * 60) / 1e3)}s`;
|
|
407
570
|
} catch (error) {
|
|
408
|
-
console.error("Error calculating duration
|
|
571
|
+
console.error("Error calculating duration", createSafeErrorDiagnostic(error));
|
|
409
572
|
return "Unknown duration";
|
|
410
573
|
}
|
|
411
574
|
}
|
|
@@ -503,7 +666,7 @@ async function fetchAllRoutineFolders(hevyClient) {
|
|
|
503
666
|
} while (page <= pageCount);
|
|
504
667
|
return allFolders;
|
|
505
668
|
}
|
|
506
|
-
function registerHevyResources(server, hevyClient) {
|
|
669
|
+
function registerHevyResources(server, hevyClient, catalog = createExerciseTemplateCatalog()) {
|
|
507
670
|
server.registerResource("user-profile", "hevy://user", {
|
|
508
671
|
description: "Authenticated Hevy user profile",
|
|
509
672
|
mimeType: JSON_MIME_TYPE
|
|
@@ -520,7 +683,8 @@ function registerHevyResources(server, hevyClient) {
|
|
|
520
683
|
description: "Full formatted Hevy exercise template catalog",
|
|
521
684
|
mimeType: JSON_MIME_TYPE
|
|
522
685
|
}, async (uri) => {
|
|
523
|
-
|
|
686
|
+
const client = requireClient(hevyClient);
|
|
687
|
+
return createJsonResourceResult(uri, (await catalog.get(client)).map(formatExerciseTemplate));
|
|
524
688
|
});
|
|
525
689
|
server.registerResource("routine-folders", "hevy://routine-folders", {
|
|
526
690
|
description: "Full formatted list of Hevy routine folders",
|
|
@@ -530,13 +694,24 @@ function registerHevyResources(server, hevyClient) {
|
|
|
530
694
|
});
|
|
531
695
|
}
|
|
532
696
|
//#endregion
|
|
697
|
+
//#region src/server-metadata.ts
|
|
698
|
+
const SERVER_NAME = "hevy-mcp";
|
|
699
|
+
const SERVER_VERSION = "3.1.1";
|
|
700
|
+
const SERVER_INSTRUCTIONS = [
|
|
701
|
+
"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
|
+
"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.",
|
|
703
|
+
"Workflow: search exercise templates first, then use the returned template IDs when creating workouts or routines. To create a completed workout from a routine, fetch the routine as a plan, then obtain the actual completed sets and end time from the user; never invent completion data.",
|
|
704
|
+
"Pagination: start at page 1 and fetch only the pages needed. Most list tools allow pageSize up to 10; get-exercise-templates allows up to 100.",
|
|
705
|
+
"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
|
+
].join("\n\n");
|
|
707
|
+
//#endregion
|
|
533
708
|
//#region src/utils/error-classification.ts
|
|
534
709
|
/**
|
|
535
710
|
* Determine the type of error based on error characteristics.
|
|
536
711
|
*/
|
|
537
712
|
function determineErrorType(error, message) {
|
|
538
713
|
if (error !== null && typeof error === "object" && error.hevyRetryExhausted === true) return "NETWORK_ERROR";
|
|
539
|
-
if (
|
|
714
|
+
if (isHevyHttpError(error) && error.status === 429) return "RATE_LIMIT";
|
|
540
715
|
const messageLower = message.toLowerCase();
|
|
541
716
|
const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
|
|
542
717
|
if (nameLower.includes("network") || messageLower.includes("network") || nameLower.includes("fetch") || messageLower.includes("fetch") || nameLower.includes("timeout") || messageLower.includes("timeout")) return "NETWORK_ERROR";
|
|
@@ -550,27 +725,26 @@ function determineErrorType(error, message) {
|
|
|
550
725
|
/**
|
|
551
726
|
* Centralized error handling utility for MCP tools
|
|
552
727
|
*/
|
|
553
|
-
function normalizeHeaderValue
|
|
728
|
+
function normalizeHeaderValue(value) {
|
|
554
729
|
if (typeof value === "string") {
|
|
555
730
|
const trimmed = value.trim();
|
|
556
731
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
557
732
|
}
|
|
558
733
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
559
|
-
if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue
|
|
734
|
+
if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
|
|
560
735
|
}
|
|
561
|
-
function getHeaderValue
|
|
736
|
+
function getHeaderValue(headers, key) {
|
|
562
737
|
if (!headers || typeof headers !== "object") return;
|
|
563
|
-
if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue
|
|
738
|
+
if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
|
|
564
739
|
const headerRecord = headers;
|
|
565
|
-
return normalizeHeaderValue
|
|
740
|
+
return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
|
|
566
741
|
}
|
|
567
742
|
function formatSecondsLabel(seconds) {
|
|
568
743
|
const roundedSeconds = Math.max(0, Math.round(seconds));
|
|
569
744
|
return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
|
|
570
745
|
}
|
|
571
746
|
function getRateLimitMessage(error) {
|
|
572
|
-
|
|
573
|
-
const retryAfterHeader = getHeaderValue$1(error.response?.headers, "retry-after");
|
|
747
|
+
const retryAfterHeader = getHeaderValue(error.headers, "retry-after");
|
|
574
748
|
if (!retryAfterHeader) return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
|
|
575
749
|
const seconds = Number(retryAfterHeader);
|
|
576
750
|
if (Number.isFinite(seconds) && seconds >= 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
|
|
@@ -588,8 +762,9 @@ function getRetryExhaustedMessage(error) {
|
|
|
588
762
|
return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
|
|
589
763
|
}
|
|
590
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;
|
|
591
766
|
if (isRetryExhaustedError(error)) return getRetryExhaustedMessage(error);
|
|
592
|
-
if (
|
|
767
|
+
if (isHevyHttpError(error) && error.status === 429) return getRateLimitMessage(error);
|
|
593
768
|
return defaultMessage;
|
|
594
769
|
}
|
|
595
770
|
/**
|
|
@@ -600,25 +775,28 @@ function getUserFacingMessage(error, defaultMessage) {
|
|
|
600
775
|
* @returns A formatted MCP tool response with error information
|
|
601
776
|
*/
|
|
602
777
|
function createErrorResponse(error, context) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const axiosErrorContext =
|
|
778
|
+
let errorMessage = "The request failed unexpectedly. Please try again.";
|
|
779
|
+
const safeDiagnostic = createSafeErrorDiagnostic(error);
|
|
780
|
+
const axiosErrorContext = safeDiagnostic.status !== void 0 || safeDiagnostic.method !== void 0 || safeDiagnostic.endpoint !== void 0 ? {
|
|
781
|
+
status: safeDiagnostic.status,
|
|
782
|
+
method: safeDiagnostic.method,
|
|
783
|
+
url: safeDiagnostic.endpoint
|
|
784
|
+
} : null;
|
|
606
785
|
const mappedHevyErrorMessage = mapHevyErrorMessageByStatus(axiosErrorContext?.status);
|
|
607
786
|
if (mappedHevyErrorMessage) errorMessage = mappedHevyErrorMessage;
|
|
608
|
-
if (
|
|
787
|
+
else if (isHevyHttpError(error) && error.status !== void 0) errorMessage = `Hevy API request failed (HTTP ${error.status}).`;
|
|
609
788
|
errorMessage = getUserFacingMessage(error, errorMessage);
|
|
610
|
-
const errorCode =
|
|
789
|
+
const errorCode = safeDiagnostic.code;
|
|
611
790
|
const errorType = determineErrorType(error, errorMessage);
|
|
612
791
|
const errorContext = {
|
|
613
792
|
sourceContext: context,
|
|
614
|
-
originalErrorMessage
|
|
793
|
+
originalErrorMessage: `${safeDiagnostic.category} occurred`,
|
|
615
794
|
errorCode,
|
|
616
795
|
errorType,
|
|
617
796
|
axios: axiosErrorContext ?? void 0
|
|
618
797
|
};
|
|
619
798
|
const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
|
|
620
|
-
|
|
621
|
-
console.error(`${formattedMessage} (Type: ${errorType}${errorCodeSuffix})`, error);
|
|
799
|
+
console.error("MCP tool failure", safeDiagnostic);
|
|
622
800
|
return {
|
|
623
801
|
content: [{
|
|
624
802
|
type: "text",
|
|
@@ -628,27 +806,6 @@ function createErrorResponse(error, context) {
|
|
|
628
806
|
errorContext
|
|
629
807
|
};
|
|
630
808
|
}
|
|
631
|
-
function extractErrorMessage(error) {
|
|
632
|
-
if (error instanceof Error) return error.message;
|
|
633
|
-
if (typeof error === "string") return error;
|
|
634
|
-
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
|
635
|
-
if (error && typeof error === "object") try {
|
|
636
|
-
return JSON.stringify(error);
|
|
637
|
-
} catch (_e) {
|
|
638
|
-
return "Unknown error object";
|
|
639
|
-
}
|
|
640
|
-
return String(error);
|
|
641
|
-
}
|
|
642
|
-
function extractAxiosErrorContext(error) {
|
|
643
|
-
if (!isAxiosError(error)) return null;
|
|
644
|
-
return {
|
|
645
|
-
status: error.response?.status,
|
|
646
|
-
statusText: error.response?.statusText,
|
|
647
|
-
data: error.response?.data,
|
|
648
|
-
method: error.config?.method,
|
|
649
|
-
url: error.config?.url
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
809
|
function mapHevyErrorMessageByStatus(status) {
|
|
653
810
|
if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
|
|
654
811
|
if (status === 404) return "The requested resource was not found in Hevy.";
|
|
@@ -658,15 +815,6 @@ function mapHevyErrorMessageByStatus(status) {
|
|
|
658
815
|
if (status && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
|
|
659
816
|
return null;
|
|
660
817
|
}
|
|
661
|
-
function stringifyErrorData(data) {
|
|
662
|
-
if (typeof data === "string") return data;
|
|
663
|
-
if (data && typeof data === "object") try {
|
|
664
|
-
return JSON.stringify(data);
|
|
665
|
-
} catch (_e) {
|
|
666
|
-
return "Unable to serialize error response data";
|
|
667
|
-
}
|
|
668
|
-
return String(data);
|
|
669
|
-
}
|
|
670
818
|
/**
|
|
671
819
|
* Wrap an async function with standardized error handling
|
|
672
820
|
*
|
|
@@ -678,209 +826,22 @@ function stringifyErrorData(data) {
|
|
|
678
826
|
* @param context - Context information for error messages
|
|
679
827
|
* @returns A function that catches errors and returns standardized error responses
|
|
680
828
|
*/
|
|
681
|
-
function withErrorHandling(fn, context) {
|
|
829
|
+
function withErrorHandling(fn, context, onError) {
|
|
682
830
|
return async (rawArgs) => {
|
|
683
831
|
const args = rawArgs ?? {};
|
|
684
832
|
try {
|
|
685
833
|
return await fn(args);
|
|
686
834
|
} catch (error) {
|
|
687
|
-
Sentry.withScope((scope) => {
|
|
688
|
-
scope.setTag("mcp.tool.context", context);
|
|
689
|
-
scope.setContext("mcpTool", {
|
|
690
|
-
context,
|
|
691
|
-
argumentKeyCount: Object.keys(args).length
|
|
692
|
-
});
|
|
693
|
-
Sentry.captureException(error);
|
|
694
|
-
});
|
|
695
|
-
return createErrorResponse(error, context);
|
|
696
|
-
}
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
//#endregion
|
|
700
|
-
//#region src/utils/debug.ts
|
|
701
|
-
const DEBUG_PREFIX = "[hevy-mcp:debug] ";
|
|
702
|
-
const MAX_DEBUG_RECORD_LENGTH = 8192;
|
|
703
|
-
const MAX_REDACTION_DEPTH = 4;
|
|
704
|
-
const MAX_OBJECT_KEYS = 20;
|
|
705
|
-
function isDebugEnabled(env = process.env) {
|
|
706
|
-
return env.HEVY_MCP_DEBUG === "1";
|
|
707
|
-
}
|
|
708
|
-
function redactValue(value, depth, seen) {
|
|
709
|
-
if (value === null) return "[null]";
|
|
710
|
-
if (typeof value !== "object") return `[${typeof value}]`;
|
|
711
|
-
if (seen.has(value)) return "[circular]";
|
|
712
|
-
if (Array.isArray(value)) {
|
|
713
|
-
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
714
|
-
const length = lengthDescriptor && "value" in lengthDescriptor && typeof lengthDescriptor.value === "number" ? lengthDescriptor.value : 0;
|
|
715
|
-
if (depth >= MAX_REDACTION_DEPTH) return {
|
|
716
|
-
type: "array",
|
|
717
|
-
length,
|
|
718
|
-
items: "[max-depth]"
|
|
719
|
-
};
|
|
720
|
-
seen.add(value);
|
|
721
|
-
try {
|
|
722
|
-
const itemCount = Math.min(length, MAX_OBJECT_KEYS);
|
|
723
|
-
const items = {};
|
|
724
|
-
for (let index = 0; index < itemCount; index += 1) {
|
|
725
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
726
|
-
items[`item-${index + 1}`] = descriptor && "value" in descriptor ? redactValue(descriptor.value, depth + 1, seen) : "[empty-or-accessor]";
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
type: "array",
|
|
730
|
-
length,
|
|
731
|
-
items,
|
|
732
|
-
...length > MAX_OBJECT_KEYS ? { truncatedItems: length - MAX_OBJECT_KEYS } : {}
|
|
733
|
-
};
|
|
734
|
-
} finally {
|
|
735
|
-
seen.delete(value);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
const keys = Object.keys(value);
|
|
739
|
-
if (depth >= MAX_REDACTION_DEPTH) return {
|
|
740
|
-
type: "object",
|
|
741
|
-
fieldCount: keys.length,
|
|
742
|
-
fields: "[max-depth]"
|
|
743
|
-
};
|
|
744
|
-
seen.add(value);
|
|
745
|
-
try {
|
|
746
|
-
const fields = {};
|
|
747
|
-
for (const [index, rawKey] of keys.slice(0, MAX_OBJECT_KEYS).entries()) {
|
|
748
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, rawKey);
|
|
749
|
-
fields[`field-${index + 1}`] = descriptor && "value" in descriptor ? redactValue(descriptor.value, depth + 1, seen) : "[accessor]";
|
|
750
|
-
}
|
|
751
|
-
return {
|
|
752
|
-
type: "object",
|
|
753
|
-
fieldCount: keys.length,
|
|
754
|
-
fields,
|
|
755
|
-
...keys.length > MAX_OBJECT_KEYS ? { truncatedFields: keys.length - MAX_OBJECT_KEYS } : {}
|
|
756
|
-
};
|
|
757
|
-
} finally {
|
|
758
|
-
seen.delete(value);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
/** Redact every input scalar while preserving bounded argument structure. */
|
|
762
|
-
function redactToolArgs(args) {
|
|
763
|
-
try {
|
|
764
|
-
return redactValue(args, 0, /* @__PURE__ */ new WeakSet());
|
|
765
|
-
} catch {
|
|
766
|
-
return "[unavailable]";
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* Write a single bounded structured debug record to stderr without throwing.
|
|
771
|
-
*/
|
|
772
|
-
function debugLog(event, data) {
|
|
773
|
-
if (!isDebugEnabled()) return;
|
|
774
|
-
try {
|
|
775
|
-
const record = JSON.stringify({
|
|
776
|
-
event,
|
|
777
|
-
...data
|
|
778
|
-
});
|
|
779
|
-
const output = record.length <= MAX_DEBUG_RECORD_LENGTH ? record : JSON.stringify({
|
|
780
|
-
event,
|
|
781
|
-
truncated: true
|
|
782
|
-
});
|
|
783
|
-
process.stderr.write(`${DEBUG_PREFIX}${output}\n`);
|
|
784
|
-
} catch {}
|
|
785
|
-
}
|
|
786
|
-
//#endregion
|
|
787
|
-
//#region src/utils/telemetry-wrapper.ts
|
|
788
|
-
/** Whitelist of safe argument keys that can be logged without exposing PII. */
|
|
789
|
-
const ARGUMENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
790
|
-
"page",
|
|
791
|
-
"pageSize",
|
|
792
|
-
"since",
|
|
793
|
-
"workoutId",
|
|
794
|
-
"routineId",
|
|
795
|
-
"folderId",
|
|
796
|
-
"exerciseTemplateId",
|
|
797
|
-
"date",
|
|
798
|
-
"startDate",
|
|
799
|
-
"endDate",
|
|
800
|
-
"updatedSince",
|
|
801
|
-
"includeCustom",
|
|
802
|
-
"limit",
|
|
803
|
-
"offset",
|
|
804
|
-
"refresh",
|
|
805
|
-
"query",
|
|
806
|
-
"primaryMuscleGroup"
|
|
807
|
-
]);
|
|
808
|
-
function extractSafeArgs(args) {
|
|
809
|
-
const attributes = {};
|
|
810
|
-
for (const [key, value] of Object.entries(args)) {
|
|
811
|
-
if (!ARGUMENT_WHITELIST.has(key)) continue;
|
|
812
|
-
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue;
|
|
813
|
-
attributes[`mcp.tool.args.${key}`] = key === "query" && typeof value === "string" && value.length > 100 ? `${value.slice(0, 100)}...` : value;
|
|
814
|
-
}
|
|
815
|
-
return attributes;
|
|
816
|
-
}
|
|
817
|
-
/**
|
|
818
|
-
* Wrap an MCP tool handler with its existing OpenTelemetry span and metrics.
|
|
819
|
-
*/
|
|
820
|
-
function withTelemetry(fn, context) {
|
|
821
|
-
return async (rawArgs) => {
|
|
822
|
-
const args = rawArgs ?? {};
|
|
823
|
-
if (isDebugEnabled()) debugLog("tool_invocation", {
|
|
824
|
-
tool: context,
|
|
825
|
-
params: redactToolArgs(args)
|
|
826
|
-
});
|
|
827
|
-
const argumentKeyCount = Object.keys(args).length;
|
|
828
|
-
const startTime = Date.now();
|
|
829
|
-
let isError = false;
|
|
830
|
-
toolInvocations.add(1, { tool_name: context });
|
|
831
|
-
const userId = getCurrentUserId();
|
|
832
|
-
const safeArgs = extractSafeArgs(args);
|
|
833
|
-
const whitelistedKeys = Object.keys(safeArgs).map((key) => key.replace("mcp.tool.args.", ""));
|
|
834
|
-
return tracer.startActiveSpan(`mcp.tool.${context}`, { attributes: {
|
|
835
|
-
"mcp.tool.name": context,
|
|
836
|
-
"mcp.tool.args.key_count": argumentKeyCount,
|
|
837
|
-
"mcp.tool.args.keys": whitelistedKeys.join(","),
|
|
838
|
-
...userId ? { "user.id": userId } : {},
|
|
839
|
-
...safeArgs
|
|
840
|
-
} }, async (span) => {
|
|
841
835
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
span.setAttribute("mcp.tool.result.is_error", isError);
|
|
846
|
-
if (result.content) {
|
|
847
|
-
span.setAttribute("mcp.tool.result.content_count", result.content.length);
|
|
848
|
-
const textLength = result.content.reduce((sum, item) => sum + (item.text?.length ?? 0), 0);
|
|
849
|
-
span.setAttribute("mcp.tool.result.text_length", textLength);
|
|
850
|
-
}
|
|
851
|
-
return result;
|
|
852
|
-
} catch (error) {
|
|
853
|
-
isError = true;
|
|
854
|
-
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
855
|
-
span.recordException(error);
|
|
856
|
-
const errorType = determineErrorType(error, error instanceof Error ? error.message : String(error));
|
|
857
|
-
span.setAttribute("error.type", errorType);
|
|
858
|
-
const rawCode = error instanceof Error && "code" in error ? error.code : void 0;
|
|
859
|
-
if (rawCode !== void 0 && rawCode !== null) span.setAttribute("error.code", String(rawCode));
|
|
860
|
-
toolErrors.add(1, {
|
|
861
|
-
tool_name: context,
|
|
862
|
-
error_type: errorType
|
|
863
|
-
});
|
|
864
|
-
throw error;
|
|
865
|
-
} finally {
|
|
866
|
-
toolDuration.record(Date.now() - startTime, {
|
|
867
|
-
tool_name: context,
|
|
868
|
-
is_error: String(isError)
|
|
869
|
-
});
|
|
870
|
-
span.end();
|
|
836
|
+
onError?.(error, context, Object.keys(args).length);
|
|
837
|
+
} catch {
|
|
838
|
+
console.error("MCP error observer failure", { category: "ObserverError" });
|
|
871
839
|
}
|
|
872
|
-
|
|
840
|
+
return createErrorResponse(error, context);
|
|
841
|
+
}
|
|
873
842
|
};
|
|
874
843
|
}
|
|
875
844
|
//#endregion
|
|
876
|
-
//#region src/utils/observability-wrapper.ts
|
|
877
|
-
/**
|
|
878
|
-
* Wrap an MCP tool handler with telemetry inside error response handling.
|
|
879
|
-
*/
|
|
880
|
-
function withObservability(fn, context) {
|
|
881
|
-
return withErrorHandling(withTelemetry(fn, context), context);
|
|
882
|
-
}
|
|
883
|
-
//#endregion
|
|
884
845
|
//#region src/generated/client/schemas/userInfoSchema.ts
|
|
885
846
|
/**
|
|
886
847
|
* Generated by Kubb (https://kubb.dev/).
|
|
@@ -1248,7 +1209,7 @@ function buildMeasurementPayload(args) {
|
|
|
1248
1209
|
}
|
|
1249
1210
|
return payload;
|
|
1250
1211
|
}
|
|
1251
|
-
function registerBodyMeasurementTools(server, hevyClient) {
|
|
1212
|
+
function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1252
1213
|
const getBodyMeasurementsSchema = {
|
|
1253
1214
|
page: z.coerce.number().int().gte(1).default(1),
|
|
1254
1215
|
pageSize: z.coerce.number().int().gte(1).lte(10).default(10)
|
|
@@ -1267,7 +1228,7 @@ function registerBodyMeasurementTools(server, hevyClient) {
|
|
|
1267
1228
|
inputSchema: getBodyMeasurementsSchema,
|
|
1268
1229
|
outputSchema: bodyMeasurementsOutputSchema,
|
|
1269
1230
|
annotations: readOnlyAnnotations("Get Body Measurements")
|
|
1270
|
-
},
|
|
1231
|
+
}, wrapHandler(async (args) => {
|
|
1271
1232
|
const client = requireClient(hevyClient);
|
|
1272
1233
|
const { page, pageSize } = args;
|
|
1273
1234
|
const measurements = (await client.getBodyMeasurements({
|
|
@@ -1292,7 +1253,7 @@ function registerBodyMeasurementTools(server, hevyClient) {
|
|
|
1292
1253
|
inputSchema: getBodyMeasurementSchema,
|
|
1293
1254
|
outputSchema: bodyMeasurementOutputSchema,
|
|
1294
1255
|
annotations: readOnlyAnnotations("Get Body Measurement")
|
|
1295
|
-
},
|
|
1256
|
+
}, wrapHandler(async (args) => {
|
|
1296
1257
|
const client = requireClient(hevyClient);
|
|
1297
1258
|
const { date } = args;
|
|
1298
1259
|
const data = await client.getBodyMeasurement(date);
|
|
@@ -1313,7 +1274,7 @@ function registerBodyMeasurementTools(server, hevyClient) {
|
|
|
1313
1274
|
],
|
|
1314
1275
|
useCase: "Use for a date without an entry; use update-body-measurement when that date already exists.",
|
|
1315
1276
|
importantNotes: "date must use YYYY-MM-DD and be unique. Null fields are omitted and cannot clear values; an existing date returns 409."
|
|
1316
|
-
}), createBodyMeasurementSchema, createAnnotations("Create Body Measurement"),
|
|
1277
|
+
}), createBodyMeasurementSchema, createAnnotations("Create Body Measurement"), wrapHandler(async (args) => {
|
|
1317
1278
|
const client = requireClient(hevyClient);
|
|
1318
1279
|
const { date, ...fields } = args;
|
|
1319
1280
|
await client.createBodyMeasurement({
|
|
@@ -1335,7 +1296,7 @@ function registerBodyMeasurementTools(server, hevyClient) {
|
|
|
1335
1296
|
],
|
|
1336
1297
|
useCase: "Use to change fields on an existing date; use create-body-measurement for a new date.",
|
|
1337
1298
|
importantNotes: "date must use YYYY-MM-DD and already exist. Provide at least one numeric field; nulls are omitted and cannot clear stored values."
|
|
1338
|
-
}), updateBodyMeasurementSchema, updateAnnotations("Update Body Measurement"),
|
|
1299
|
+
}), updateBodyMeasurementSchema, updateAnnotations("Update Body Measurement"), wrapHandler(async (args) => {
|
|
1339
1300
|
const client = requireClient(hevyClient);
|
|
1340
1301
|
const { date, ...fields } = args;
|
|
1341
1302
|
const payload = buildMeasurementPayload(fields);
|
|
@@ -1349,7 +1310,7 @@ function registerBodyMeasurementTools(server, hevyClient) {
|
|
|
1349
1310
|
/**
|
|
1350
1311
|
* Register all routine folder-related tools with the MCP server
|
|
1351
1312
|
*/
|
|
1352
|
-
function registerFolderTools(server, hevyClient) {
|
|
1313
|
+
function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1353
1314
|
const getRoutineFoldersSchema = {
|
|
1354
1315
|
page: z.coerce.number().int().gte(1).default(1),
|
|
1355
1316
|
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
|
|
@@ -1368,7 +1329,7 @@ function registerFolderTools(server, hevyClient) {
|
|
|
1368
1329
|
inputSchema: getRoutineFoldersSchema,
|
|
1369
1330
|
outputSchema: routineFoldersOutputSchema,
|
|
1370
1331
|
annotations: readOnlyAnnotations("Get Routine Folders")
|
|
1371
|
-
},
|
|
1332
|
+
}, wrapHandler(async (args) => {
|
|
1372
1333
|
const client = requireClient(hevyClient);
|
|
1373
1334
|
const { page, pageSize } = args;
|
|
1374
1335
|
const folders = (await client.getRoutineFolders({
|
|
@@ -1393,7 +1354,7 @@ function registerFolderTools(server, hevyClient) {
|
|
|
1393
1354
|
inputSchema: getRoutineFolderSchema,
|
|
1394
1355
|
outputSchema: routineFolderOutputSchema,
|
|
1395
1356
|
annotations: readOnlyAnnotations("Get Routine Folder")
|
|
1396
|
-
},
|
|
1357
|
+
}, wrapHandler(async (args) => {
|
|
1397
1358
|
const client = requireClient(hevyClient);
|
|
1398
1359
|
const { folderId } = args;
|
|
1399
1360
|
const data = await client.getRoutineFolder(folderId);
|
|
@@ -1411,7 +1372,7 @@ function registerFolderTools(server, hevyClient) {
|
|
|
1411
1372
|
],
|
|
1412
1373
|
useCase: "Use to create an organizational destination before assigning new routines to a folderId.",
|
|
1413
1374
|
importantNotes: "Requires a non-empty name. Retrying or reusing a name can create duplicate folders."
|
|
1414
|
-
}), createRoutineFolderSchema, createAnnotations("Create Routine Folder"),
|
|
1375
|
+
}), createRoutineFolderSchema, createAnnotations("Create Routine Folder"), wrapHandler(async (args) => {
|
|
1415
1376
|
const client = requireClient(hevyClient);
|
|
1416
1377
|
const { name } = args;
|
|
1417
1378
|
const data = await client.createRoutineFolder({ routine_folder: { title: name } });
|
|
@@ -1470,7 +1431,7 @@ const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (r
|
|
|
1470
1431
|
/**
|
|
1471
1432
|
* Register all routine-related tools with the MCP server
|
|
1472
1433
|
*/
|
|
1473
|
-
function registerRoutineTools(server, hevyClient) {
|
|
1434
|
+
function registerRoutineTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1474
1435
|
const getRoutinesSchema = {
|
|
1475
1436
|
page: z.coerce.number().int().gte(1).default(1),
|
|
1476
1437
|
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
|
|
@@ -1489,7 +1450,7 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
1489
1450
|
inputSchema: getRoutinesSchema,
|
|
1490
1451
|
outputSchema: routinesOutputSchema,
|
|
1491
1452
|
annotations: readOnlyAnnotations("Get Routines")
|
|
1492
|
-
},
|
|
1453
|
+
}, wrapHandler(async (args) => {
|
|
1493
1454
|
const client = requireClient(hevyClient);
|
|
1494
1455
|
const { page, pageSize } = args;
|
|
1495
1456
|
const routines = (await client.getRoutines({
|
|
@@ -1514,7 +1475,7 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
1514
1475
|
inputSchema: getRoutineSchema,
|
|
1515
1476
|
outputSchema: routineOutputSchema,
|
|
1516
1477
|
annotations: readOnlyAnnotations("Get Routine")
|
|
1517
|
-
},
|
|
1478
|
+
}, wrapHandler(async (args) => {
|
|
1518
1479
|
const client = requireClient(hevyClient);
|
|
1519
1480
|
const { routineId } = args;
|
|
1520
1481
|
const data = await client.getRoutineById(String(routineId));
|
|
@@ -1554,7 +1515,7 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
1554
1515
|
],
|
|
1555
1516
|
useCase: "Use to create a reusable plan; use create-workout to log a completed session.",
|
|
1556
1517
|
importantNotes: "Requires exercise template IDs; folderId is optional. Retrying can create duplicates, and non-fixed rep ranges may not display in Hevy apps."
|
|
1557
|
-
}), createRoutineSchema, createAnnotations("Create Routine"),
|
|
1518
|
+
}), createRoutineSchema, createAnnotations("Create Routine"), wrapHandler(async (args) => {
|
|
1558
1519
|
const client = requireClient(hevyClient);
|
|
1559
1520
|
const { title, folderId, notes, exercises } = args;
|
|
1560
1521
|
let usesRepRanges = false;
|
|
@@ -1630,7 +1591,7 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
1630
1591
|
],
|
|
1631
1592
|
useCase: "Use to change a known routine; use create-routine for a separate new plan.",
|
|
1632
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."
|
|
1633
|
-
}), updateRoutineSchema, updateAnnotations("Update Routine"),
|
|
1594
|
+
}), updateRoutineSchema, updateAnnotations("Update Routine"), wrapHandler(async (args) => {
|
|
1634
1595
|
const client = requireClient(hevyClient);
|
|
1635
1596
|
const { routineId, title, notes, exercises } = args;
|
|
1636
1597
|
let usesRepRanges = false;
|
|
@@ -1680,7 +1641,7 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
1680
1641
|
* Register all exercise template-related tools with the MCP server
|
|
1681
1642
|
*/
|
|
1682
1643
|
function registerTemplateTools(server, hevyClient, options = {}) {
|
|
1683
|
-
const { logger } = options;
|
|
1644
|
+
const { catalog = createExerciseTemplateCatalog(), logger, wrapHandler = withErrorHandling } = options;
|
|
1684
1645
|
const getExerciseTemplatesSchema = {
|
|
1685
1646
|
page: z.coerce.number().int().gte(1).default(1),
|
|
1686
1647
|
pageSize: z.coerce.number().int().gte(1).lte(100).default(5)
|
|
@@ -1699,7 +1660,7 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1699
1660
|
inputSchema: getExerciseTemplatesSchema,
|
|
1700
1661
|
outputSchema: exerciseTemplatesOutputSchema,
|
|
1701
1662
|
annotations: readOnlyAnnotations("Get Exercise Templates")
|
|
1702
|
-
},
|
|
1663
|
+
}, wrapHandler(async (args) => {
|
|
1703
1664
|
const client = requireClient(hevyClient);
|
|
1704
1665
|
const { page, pageSize } = args;
|
|
1705
1666
|
const templates = (await client.getExerciseTemplates({
|
|
@@ -1724,7 +1685,7 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1724
1685
|
inputSchema: getExerciseTemplateSchema,
|
|
1725
1686
|
outputSchema: exerciseTemplateOutputSchema,
|
|
1726
1687
|
annotations: readOnlyAnnotations("Get Exercise Template")
|
|
1727
|
-
},
|
|
1688
|
+
}, wrapHandler(async (args) => {
|
|
1728
1689
|
const client = requireClient(hevyClient);
|
|
1729
1690
|
const { exerciseTemplateId } = args;
|
|
1730
1691
|
const data = await client.getExerciseTemplate(exerciseTemplateId);
|
|
@@ -1751,7 +1712,7 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1751
1712
|
inputSchema: getExerciseHistorySchema,
|
|
1752
1713
|
outputSchema: exerciseHistoryOutputSchema,
|
|
1753
1714
|
annotations: readOnlyAnnotations("Get Exercise History")
|
|
1754
|
-
},
|
|
1715
|
+
}, wrapHandler(async (args) => {
|
|
1755
1716
|
const client = requireClient(hevyClient);
|
|
1756
1717
|
const { exerciseTemplateId, startDate, endDate } = args;
|
|
1757
1718
|
const history = (await client.getExerciseHistory(exerciseTemplateId, {
|
|
@@ -1777,7 +1738,7 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1777
1738
|
],
|
|
1778
1739
|
useCase: "Use only when the needed movement is absent; search-exercise-templates should check existing templates first.",
|
|
1779
1740
|
importantNotes: "Requires title, exercise type, equipment category, and primary muscle group. Retrying or reusing a title can create duplicates."
|
|
1780
|
-
}), createExerciseTemplateSchema, createAnnotations("Create Exercise Template"),
|
|
1741
|
+
}), createExerciseTemplateSchema, createAnnotations("Create Exercise Template"), wrapHandler(async (args) => {
|
|
1781
1742
|
const client = requireClient(hevyClient);
|
|
1782
1743
|
const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
|
|
1783
1744
|
return createJsonResponse({
|
|
@@ -1810,10 +1771,10 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1810
1771
|
inputSchema: searchExerciseTemplatesSchema,
|
|
1811
1772
|
outputSchema: exerciseTemplatesOutputSchema,
|
|
1812
1773
|
annotations: readOnlyAnnotations("Search Exercise Templates")
|
|
1813
|
-
},
|
|
1774
|
+
}, wrapHandler(async (args) => {
|
|
1814
1775
|
const client = requireClient(hevyClient);
|
|
1815
1776
|
const { query, primaryMuscleGroup, refresh } = args;
|
|
1816
|
-
const
|
|
1777
|
+
const templates = await catalog.get(client, {
|
|
1817
1778
|
refresh,
|
|
1818
1779
|
onRefreshed: (refreshedCatalog, reason) => {
|
|
1819
1780
|
try {
|
|
@@ -1827,12 +1788,12 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1827
1788
|
}
|
|
1828
1789
|
});
|
|
1829
1790
|
} catch (error) {
|
|
1830
|
-
console.error("Failed to emit structured exercise template cache log", error);
|
|
1791
|
+
console.error("Failed to emit structured exercise template cache log", createSafeErrorDiagnostic(error));
|
|
1831
1792
|
}
|
|
1832
1793
|
}
|
|
1833
1794
|
});
|
|
1834
1795
|
const queryLower = query.toLowerCase();
|
|
1835
|
-
let results =
|
|
1796
|
+
let results = templates.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
|
|
1836
1797
|
if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
|
|
1837
1798
|
if (results.length === 0) return createStructuredEmptyResponse(`No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}`, { exerciseTemplates: [] });
|
|
1838
1799
|
const exerciseTemplates = results.map(formatExerciseTemplate);
|
|
@@ -1841,7 +1802,7 @@ function registerTemplateTools(server, hevyClient, options = {}) {
|
|
|
1841
1802
|
}
|
|
1842
1803
|
//#endregion
|
|
1843
1804
|
//#region src/tools/user.ts
|
|
1844
|
-
function registerUserTools(server, hevyClient) {
|
|
1805
|
+
function registerUserTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1845
1806
|
server.registerTool("get-user-info", {
|
|
1846
1807
|
description: describeTool({
|
|
1847
1808
|
summary: "Read-only. Returns the authenticated account's user ID, display name, and public profile URL.",
|
|
@@ -1856,7 +1817,7 @@ function registerUserTools(server, hevyClient) {
|
|
|
1856
1817
|
inputSchema: {},
|
|
1857
1818
|
outputSchema: userOutputSchema,
|
|
1858
1819
|
annotations: readOnlyAnnotations("Get User Info")
|
|
1859
|
-
},
|
|
1820
|
+
}, wrapHandler(async (_args) => {
|
|
1860
1821
|
const data = await requireClient(hevyClient).getUserInfo();
|
|
1861
1822
|
if (!data?.data) return createStructuredEmptyResponse("No user info found for the authenticated user", { user: null });
|
|
1862
1823
|
return createStructuredJsonResponse(data.data, { user: data.data });
|
|
@@ -1879,7 +1840,7 @@ function formatWorkoutEvent(event) {
|
|
|
1879
1840
|
/**
|
|
1880
1841
|
* Register all workout-related tools with the MCP server
|
|
1881
1842
|
*/
|
|
1882
|
-
function registerWorkoutTools(server, hevyClient) {
|
|
1843
|
+
function registerWorkoutTools(server, hevyClient, wrapHandler = withErrorHandling) {
|
|
1883
1844
|
const getWorkoutsSchema = {
|
|
1884
1845
|
page: z.coerce.number().gte(1).default(1),
|
|
1885
1846
|
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
|
|
@@ -1898,7 +1859,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
1898
1859
|
inputSchema: getWorkoutsSchema,
|
|
1899
1860
|
outputSchema: workoutsOutputSchema,
|
|
1900
1861
|
annotations: readOnlyAnnotations("Get Workouts")
|
|
1901
|
-
},
|
|
1862
|
+
}, wrapHandler(async (args) => {
|
|
1902
1863
|
const client = requireClient(hevyClient);
|
|
1903
1864
|
const { page, pageSize } = args;
|
|
1904
1865
|
const workouts = (await client.getWorkouts({
|
|
@@ -1923,7 +1884,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
1923
1884
|
inputSchema: getWorkoutSchema,
|
|
1924
1885
|
outputSchema: workoutOutputSchema,
|
|
1925
1886
|
annotations: readOnlyAnnotations("Get Workout")
|
|
1926
|
-
},
|
|
1887
|
+
}, wrapHandler(async (args) => {
|
|
1927
1888
|
const client = requireClient(hevyClient);
|
|
1928
1889
|
const { workoutId } = args;
|
|
1929
1890
|
const data = await client.getWorkout(workoutId);
|
|
@@ -1945,7 +1906,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
1945
1906
|
inputSchema: {},
|
|
1946
1907
|
outputSchema: workoutCountOutputSchema,
|
|
1947
1908
|
annotations: readOnlyAnnotations("Get Workout Count")
|
|
1948
|
-
},
|
|
1909
|
+
}, wrapHandler(async () => {
|
|
1949
1910
|
const count = (await requireClient(hevyClient).getWorkoutCount())?.workout_count ?? 0;
|
|
1950
1911
|
return createStructuredJsonResponse({ count }, { count });
|
|
1951
1912
|
}, "get-workout-count"));
|
|
@@ -1968,7 +1929,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
1968
1929
|
inputSchema: getWorkoutEventsSchema,
|
|
1969
1930
|
outputSchema: workoutEventsOutputSchema,
|
|
1970
1931
|
annotations: readOnlyAnnotations("Get Workout Events")
|
|
1971
|
-
},
|
|
1932
|
+
}, wrapHandler(async (args) => {
|
|
1972
1933
|
const client = requireClient(hevyClient);
|
|
1973
1934
|
const { page, pageSize, since } = args;
|
|
1974
1935
|
const events = (await client.getWorkoutEvents({
|
|
@@ -2012,7 +1973,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
2012
1973
|
],
|
|
2013
1974
|
useCase: "Use to add a completed workout; use update-workout only when modifying an existing workout ID.",
|
|
2014
1975
|
importantNotes: "Requires UTC startTime/endTime in YYYY-MM-DDTHH:mm:ssZ form and exercise template IDs. Retrying can create duplicates."
|
|
2015
|
-
}), createWorkoutSchema, createAnnotations("Create Workout"),
|
|
1976
|
+
}), createWorkoutSchema, createAnnotations("Create Workout"), wrapHandler(async (args) => {
|
|
2016
1977
|
const client = requireClient(hevyClient);
|
|
2017
1978
|
const { title, description, startTime, endTime, isPrivate, exercises } = args;
|
|
2018
1979
|
const requestBody = { workout: {
|
|
@@ -2077,7 +2038,7 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
2077
2038
|
],
|
|
2078
2039
|
useCase: "Use to revise a known workout; use create-workout for a new training session.",
|
|
2079
2040
|
importantNotes: "Requires workoutId plus the complete title, times, privacy, exercises, and sets payload; omitted optional values may be cleared or defaulted."
|
|
2080
|
-
}), updateWorkoutSchema, updateAnnotations("Update Workout"),
|
|
2041
|
+
}), updateWorkoutSchema, updateAnnotations("Update Workout"), wrapHandler(async (args) => {
|
|
2081
2042
|
const client = requireClient(hevyClient);
|
|
2082
2043
|
const { workoutId, title, description, startTime, endTime, isPrivate, exercises } = args;
|
|
2083
2044
|
const requestBody = { workout: {
|
|
@@ -2110,131 +2071,24 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
2110
2071
|
}, "update-workout-operation"));
|
|
2111
2072
|
}
|
|
2112
2073
|
//#endregion
|
|
2113
|
-
//#region src/
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
*/
|
|
2128
|
-
function parseConfig(argv, env) {
|
|
2129
|
-
let apiKey = "";
|
|
2130
|
-
let usedDeprecatedApiKeyArg = false;
|
|
2131
|
-
const apiKeyArgPatterns = [
|
|
2132
|
-
/^--hevy-api-key=(.+)$/i,
|
|
2133
|
-
/^--hevyApiKey=(.+)$/i,
|
|
2134
|
-
/^hevy-api-key=(.+)$/i
|
|
2135
|
-
];
|
|
2136
|
-
for (const raw of argv) {
|
|
2137
|
-
for (const pattern of apiKeyArgPatterns) {
|
|
2138
|
-
const m = raw.match(pattern);
|
|
2139
|
-
if (m) {
|
|
2140
|
-
apiKey = m[1];
|
|
2141
|
-
usedDeprecatedApiKeyArg = true;
|
|
2142
|
-
break;
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
if (apiKey) break;
|
|
2146
|
-
}
|
|
2147
|
-
if (usedDeprecatedApiKeyArg) console.error(DEPRECATED_CLI_ARGUMENT_WARNING);
|
|
2148
|
-
if (!apiKey) apiKey = env.HEVY_API_KEY || "";
|
|
2149
|
-
return { apiKey };
|
|
2150
|
-
}
|
|
2151
|
-
function assertApiKey(apiKey) {
|
|
2152
|
-
if (!apiKey) {
|
|
2153
|
-
console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable.");
|
|
2154
|
-
process.exit(1);
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
//#endregion
|
|
2158
|
-
//#region src/utils/graceful-shutdown.ts
|
|
2159
|
-
const shutdownSignals = ["SIGINT", "SIGTERM"];
|
|
2160
|
-
const FORCED_EXIT_TIMEOUT_MS = 5e3;
|
|
2161
|
-
function flushStdout(stdout = process.stdout) {
|
|
2162
|
-
return new Promise((resolve, reject) => {
|
|
2163
|
-
try {
|
|
2164
|
-
stdout.write("", (error) => {
|
|
2165
|
-
if (error) {
|
|
2166
|
-
reject(error);
|
|
2167
|
-
return;
|
|
2168
|
-
}
|
|
2169
|
-
resolve();
|
|
2170
|
-
});
|
|
2171
|
-
} catch (error) {
|
|
2172
|
-
reject(error);
|
|
2173
|
-
}
|
|
2174
|
-
});
|
|
2175
|
-
}
|
|
2176
|
-
function installGracefulShutdown({ target, process: processLike = process, logError = console.error, flush = flushStdout, forcedExitTimeoutMs = FORCED_EXIT_TIMEOUT_MS, scheduleForcedExit = setTimeout }) {
|
|
2177
|
-
let listenersInstalled = true;
|
|
2178
|
-
let shutdownSettled = false;
|
|
2179
|
-
let shutdownPromise;
|
|
2180
|
-
const cleanup = () => {
|
|
2181
|
-
if (!listenersInstalled || shutdownPromise && !shutdownSettled) return;
|
|
2182
|
-
listenersInstalled = false;
|
|
2183
|
-
for (const signal of shutdownSignals) {
|
|
2184
|
-
const listener = signalListeners.get(signal);
|
|
2185
|
-
if (listener) processLike.removeListener(signal, listener);
|
|
2186
|
-
}
|
|
2187
|
-
};
|
|
2188
|
-
const handleSignal = (signal) => {
|
|
2189
|
-
if (shutdownPromise) return;
|
|
2190
|
-
if (processLike.exitCode == null) processLike.exitCode = 0;
|
|
2191
|
-
scheduleForcedExit(() => {
|
|
2192
|
-
processLike.exit(processLike.exitCode ?? 0);
|
|
2193
|
-
}, forcedExitTimeoutMs).unref();
|
|
2194
|
-
shutdownPromise = (async () => {
|
|
2195
|
-
logError(`Shutting down gracefully after ${signal}`);
|
|
2196
|
-
let shutdownFailed = false;
|
|
2197
|
-
let shutdownError;
|
|
2198
|
-
try {
|
|
2199
|
-
await target.close();
|
|
2200
|
-
} catch (error) {
|
|
2201
|
-
shutdownFailed = true;
|
|
2202
|
-
shutdownError = error;
|
|
2203
|
-
}
|
|
2204
|
-
try {
|
|
2205
|
-
await flush();
|
|
2206
|
-
} catch (error) {
|
|
2207
|
-
if (!shutdownFailed) shutdownError = error;
|
|
2208
|
-
shutdownFailed = true;
|
|
2209
|
-
}
|
|
2210
|
-
try {
|
|
2211
|
-
if (shutdownFailed) {
|
|
2212
|
-
logError(`Graceful shutdown failed: ${shutdownError instanceof Error ? shutdownError.message : "Unknown shutdown error"}`);
|
|
2213
|
-
processLike.exitCode = 1;
|
|
2214
|
-
return;
|
|
2215
|
-
}
|
|
2216
|
-
} finally {
|
|
2217
|
-
shutdownSettled = true;
|
|
2218
|
-
cleanup();
|
|
2219
|
-
}
|
|
2220
|
-
})();
|
|
2221
|
-
};
|
|
2222
|
-
const signalListeners = new Map(shutdownSignals.map((signal) => [signal, () => handleSignal(signal)]));
|
|
2223
|
-
for (const signal of shutdownSignals) {
|
|
2224
|
-
const listener = signalListeners.get(signal);
|
|
2225
|
-
if (listener) processLike.on(signal, listener);
|
|
2226
|
-
}
|
|
2227
|
-
return {
|
|
2228
|
-
cleanup,
|
|
2229
|
-
getShutdownPromise: () => shutdownPromise
|
|
2230
|
-
};
|
|
2074
|
+
//#region src/tools/register.ts
|
|
2075
|
+
/** Register every Hevy tool in its production ordering. */
|
|
2076
|
+
function registerHevyTools(server, hevyClient, options = {}) {
|
|
2077
|
+
const wrapHandler = options.wrapHandler ?? withErrorHandling;
|
|
2078
|
+
registerWorkoutTools(server, hevyClient, wrapHandler);
|
|
2079
|
+
registerRoutineTools(server, hevyClient, wrapHandler);
|
|
2080
|
+
registerTemplateTools(server, hevyClient, {
|
|
2081
|
+
catalog: options.catalog,
|
|
2082
|
+
logger: options.logger,
|
|
2083
|
+
wrapHandler
|
|
2084
|
+
});
|
|
2085
|
+
registerFolderTools(server, hevyClient, wrapHandler);
|
|
2086
|
+
registerBodyMeasurementTools(server, hevyClient, wrapHandler);
|
|
2087
|
+
registerUserTools(server, hevyClient, wrapHandler);
|
|
2231
2088
|
}
|
|
2232
2089
|
//#endregion
|
|
2233
2090
|
//#region src/generated/.kubb/fetch.ts
|
|
2234
|
-
let _config = {
|
|
2235
|
-
baseURL: typeof AXIOS_BASE !== "undefined" ? AXIOS_BASE : void 0,
|
|
2236
|
-
headers: typeof AXIOS_HEADERS !== "undefined" ? JSON.parse(AXIOS_HEADERS) : void 0
|
|
2237
|
-
};
|
|
2091
|
+
let _config = {};
|
|
2238
2092
|
const getConfig = () => _config;
|
|
2239
2093
|
const setConfig = (config) => {
|
|
2240
2094
|
_config = config;
|
|
@@ -2246,17 +2100,37 @@ const mergeConfig = (...configs) => {
|
|
|
2246
2100
|
...merged,
|
|
2247
2101
|
...config,
|
|
2248
2102
|
headers: {
|
|
2249
|
-
...merged.headers,
|
|
2250
|
-
...config.headers
|
|
2103
|
+
...Array.isArray(merged.headers) ? Object.fromEntries(merged.headers) : merged.headers,
|
|
2104
|
+
...Array.isArray(config.headers) ? Object.fromEntries(config.headers) : config.headers
|
|
2251
2105
|
}
|
|
2252
2106
|
};
|
|
2253
2107
|
}, {});
|
|
2254
2108
|
};
|
|
2255
|
-
const
|
|
2256
|
-
const
|
|
2257
|
-
|
|
2258
|
-
|
|
2109
|
+
const fetch$1 = async (paramsConfig) => {
|
|
2110
|
+
const normalizedParams = new URLSearchParams();
|
|
2111
|
+
const config = mergeConfig(getConfig(), paramsConfig);
|
|
2112
|
+
Object.entries(config.params || {}).forEach(([key, value]) => {
|
|
2113
|
+
if (value !== void 0) normalizedParams.append(key, value === null ? "null" : value.toString());
|
|
2114
|
+
});
|
|
2115
|
+
let targetUrl = [config.baseURL, config.url].filter(Boolean).join("");
|
|
2116
|
+
if (config.params) targetUrl += `?${normalizedParams}`;
|
|
2117
|
+
const response = await globalThis.fetch(targetUrl, {
|
|
2118
|
+
credentials: config.credentials || "same-origin",
|
|
2119
|
+
method: config.method?.toUpperCase(),
|
|
2120
|
+
body: config.data instanceof FormData ? config.data : JSON.stringify(config.data),
|
|
2121
|
+
signal: config.signal,
|
|
2122
|
+
headers: config.headers
|
|
2259
2123
|
});
|
|
2124
|
+
return {
|
|
2125
|
+
data: [
|
|
2126
|
+
204,
|
|
2127
|
+
205,
|
|
2128
|
+
304
|
|
2129
|
+
].includes(response.status) || !response.body ? {} : await response.json(),
|
|
2130
|
+
status: response.status,
|
|
2131
|
+
statusText: response.statusText,
|
|
2132
|
+
headers: response.headers
|
|
2133
|
+
};
|
|
2260
2134
|
};
|
|
2261
2135
|
fetch$1.getConfig = getConfig;
|
|
2262
2136
|
fetch$1.setConfig = setConfig;
|
|
@@ -2814,38 +2688,9 @@ async function putV1WorkoutsWorkoutid(workoutId, data, headers, config = {}) {
|
|
|
2814
2688
|
}
|
|
2815
2689
|
//#endregion
|
|
2816
2690
|
//#region src/utils/hevyClientKubb.ts
|
|
2817
|
-
/**
|
|
2818
|
-
* Type-safe wrapper helper that enforces parameter types match the generated API.
|
|
2819
|
-
* This prevents arg-order regressions by using Parameters<> to extract expected types.
|
|
2820
|
-
*
|
|
2821
|
-
* Usage: wrapApi(api.postV1ExerciseTemplates)(data, headers, { client })
|
|
2822
|
-
* TypeScript will error if arguments don't match the generated signature.
|
|
2823
|
-
*/
|
|
2824
|
-
function wrapApi(fn) {
|
|
2825
|
-
return fn;
|
|
2826
|
-
}
|
|
2827
2691
|
const DEFAULT_API_TIMEOUT_MS = 3e4;
|
|
2828
2692
|
const RETRY_BACKOFF_MAX_MS = 5e3;
|
|
2829
|
-
const
|
|
2830
|
-
const RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([
|
|
2831
|
-
408,
|
|
2832
|
-
429,
|
|
2833
|
-
500,
|
|
2834
|
-
502,
|
|
2835
|
-
503,
|
|
2836
|
-
504
|
|
2837
|
-
]);
|
|
2838
|
-
const RETRYABLE_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2839
|
-
"ECONNABORTED",
|
|
2840
|
-
"ECONNREFUSED",
|
|
2841
|
-
"ECONNRESET",
|
|
2842
|
-
"EAI_AGAIN",
|
|
2843
|
-
"ENETUNREACH",
|
|
2844
|
-
"ENOTFOUND",
|
|
2845
|
-
"ERR_NETWORK",
|
|
2846
|
-
"ERR_SOCKET_TIMEOUT",
|
|
2847
|
-
"ETIMEDOUT"
|
|
2848
|
-
]);
|
|
2693
|
+
const RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 429]);
|
|
2849
2694
|
const SAFE_STATIC_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
2850
2695
|
"/v1/body_measurements",
|
|
2851
2696
|
"/v1/exercise_templates",
|
|
@@ -2864,86 +2709,21 @@ const SAFE_DYNAMIC_ENDPOINTS = [
|
|
|
2864
2709
|
["/v1/routines/", "/v1/routines/:routineId"],
|
|
2865
2710
|
["/v1/workouts/", "/v1/workouts/:workoutId"]
|
|
2866
2711
|
];
|
|
2867
|
-
function
|
|
2868
|
-
|
|
2869
|
-
if (!rawValue) return DEFAULT_API_TIMEOUT_MS;
|
|
2870
|
-
const parsed = Number(rawValue);
|
|
2871
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_TIMEOUT_MS;
|
|
2872
|
-
return Math.trunc(parsed);
|
|
2873
|
-
}
|
|
2874
|
-
function normalizeMaxGetRetries(value) {
|
|
2875
|
-
if (value === void 0 || !Number.isFinite(value) || value < 0) return 3;
|
|
2876
|
-
return Math.floor(value);
|
|
2877
|
-
}
|
|
2878
|
-
function normalizeTimeoutMs(value) {
|
|
2879
|
-
if (value === void 0 || !Number.isFinite(value) || value <= 0) return getApiTimeoutMs();
|
|
2880
|
-
const normalizedValue = Math.floor(value);
|
|
2881
|
-
return normalizedValue > 0 ? normalizedValue : getApiTimeoutMs();
|
|
2882
|
-
}
|
|
2883
|
-
function sleep(ms) {
|
|
2884
|
-
return new Promise((resolve) => {
|
|
2885
|
-
setTimeout(resolve, ms);
|
|
2886
|
-
});
|
|
2887
|
-
}
|
|
2888
|
-
function isGetRequest(config) {
|
|
2889
|
-
return config.method?.toUpperCase() === "GET";
|
|
2890
|
-
}
|
|
2891
|
-
function isRetryableStatus(status) {
|
|
2892
|
-
if (status === void 0) return false;
|
|
2893
|
-
return RETRYABLE_STATUS_CODES.has(status);
|
|
2894
|
-
}
|
|
2895
|
-
function isTransientNetworkError(error) {
|
|
2896
|
-
if (error.code === "ERR_CANCELED") return false;
|
|
2897
|
-
if (error.code && RETRYABLE_NETWORK_ERROR_CODES.has(error.code)) return true;
|
|
2898
|
-
if (error.response) return false;
|
|
2899
|
-
const message = error.message.toLowerCase();
|
|
2900
|
-
return message.includes("network") || message.includes("socket hang up") || message.includes("timeout");
|
|
2901
|
-
}
|
|
2902
|
-
function normalizeHeaderValue(value) {
|
|
2903
|
-
if (typeof value === "string") {
|
|
2904
|
-
const trimmed = value.trim();
|
|
2905
|
-
return trimmed.length > 0 ? trimmed : void 0;
|
|
2906
|
-
}
|
|
2907
|
-
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
2908
|
-
if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
|
|
2909
|
-
}
|
|
2910
|
-
function getHeaderValue(headers, key) {
|
|
2911
|
-
if (!headers || typeof headers !== "object") return;
|
|
2912
|
-
if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
|
|
2913
|
-
const headerRecord = headers;
|
|
2914
|
-
return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
|
|
2915
|
-
}
|
|
2916
|
-
function parseRetryAfterMs(value) {
|
|
2917
|
-
const numericSeconds = Number(value);
|
|
2918
|
-
if (Number.isFinite(numericSeconds) && numericSeconds >= 0) return Math.min(RETRY_BACKOFF_MAX_MS, Math.round(numericSeconds * 1e3));
|
|
2919
|
-
const dateMillis = Date.parse(value);
|
|
2920
|
-
if (Number.isNaN(dateMillis)) return;
|
|
2921
|
-
return Math.min(RETRY_BACKOFF_MAX_MS, Math.max(0, dateMillis - Date.now()));
|
|
2922
|
-
}
|
|
2923
|
-
function getRetryAfterDelayMs(error) {
|
|
2924
|
-
if (error.response?.status !== 429) return;
|
|
2925
|
-
const retryAfterHeader = getHeaderValue(error.response.headers, "retry-after");
|
|
2926
|
-
if (!retryAfterHeader) return;
|
|
2927
|
-
return parseRetryAfterMs(retryAfterHeader);
|
|
2712
|
+
function wrapApi(fn) {
|
|
2713
|
+
return fn;
|
|
2928
2714
|
}
|
|
2929
|
-
function
|
|
2930
|
-
|
|
2931
|
-
return Math.min(RETRY_BACKOFF_MAX_MS, delay);
|
|
2715
|
+
function normalizePositiveInteger(value, fallback) {
|
|
2716
|
+
return value === void 0 || !Number.isFinite(value) || value <= 0 ? fallback : Math.max(1, Math.floor(value));
|
|
2932
2717
|
}
|
|
2933
|
-
function
|
|
2934
|
-
|
|
2935
|
-
const retryAfterDelay = getRetryAfterDelayMs(error);
|
|
2936
|
-
if (retryAfterDelay === void 0) return exponentialDelay;
|
|
2937
|
-
return Math.min(RETRY_BACKOFF_MAX_MS, Math.max(exponentialDelay, retryAfterDelay));
|
|
2718
|
+
function normalizeMaxGetRetries(value) {
|
|
2719
|
+
return value === void 0 || !Number.isFinite(value) || value < 0 ? 3 : Math.floor(value);
|
|
2938
2720
|
}
|
|
2939
|
-
function
|
|
2940
|
-
|
|
2941
|
-
return isRetryableStatus(error.response?.status) || isTransientNetworkError(error);
|
|
2721
|
+
function defaultSleep(milliseconds) {
|
|
2722
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2942
2723
|
}
|
|
2943
2724
|
function getRequestContext(config) {
|
|
2944
2725
|
const method = (config.method ?? "GET").toUpperCase();
|
|
2945
|
-
const
|
|
2946
|
-
const rawEndpoint = url.split("?")[0] ?? url;
|
|
2726
|
+
const rawEndpoint = (config.url ?? "").split("?")[0] ?? "";
|
|
2947
2727
|
let endpoint = "unknown";
|
|
2948
2728
|
if (SAFE_STATIC_ENDPOINTS.has(rawEndpoint)) endpoint = rawEndpoint;
|
|
2949
2729
|
else endpoint = SAFE_DYNAMIC_ENDPOINTS.find(([prefix]) => rawEndpoint.startsWith(prefix))?.[1] ?? "unknown";
|
|
@@ -2955,206 +2735,180 @@ function getRequestContext(config) {
|
|
|
2955
2735
|
function emitClientLog(logger, message) {
|
|
2956
2736
|
try {
|
|
2957
2737
|
logger?.(message);
|
|
2958
|
-
} catch
|
|
2959
|
-
console.error("Failed to emit structured Hevy API log"
|
|
2738
|
+
} catch {
|
|
2739
|
+
console.error("Failed to emit structured Hevy API log");
|
|
2960
2740
|
}
|
|
2961
2741
|
}
|
|
2962
|
-
function
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2742
|
+
function parseRetryAfterMs(value) {
|
|
2743
|
+
if (!value) return void 0;
|
|
2744
|
+
const seconds = Number(value);
|
|
2745
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(RETRY_BACKOFF_MAX_MS, Math.round(seconds * 1e3));
|
|
2746
|
+
const dateMillis = Date.parse(value);
|
|
2747
|
+
return Number.isNaN(dateMillis) ? void 0 : Math.min(RETRY_BACKOFF_MAX_MS, Math.max(0, dateMillis - Date.now()));
|
|
2748
|
+
}
|
|
2749
|
+
function getRetryDelayMs(error, retryAttempt) {
|
|
2750
|
+
const exponential = Math.min(RETRY_BACKOFF_MAX_MS, 300 * 2 ** Math.max(0, retryAttempt - 1));
|
|
2751
|
+
const retryAfter = error.status === 429 ? parseRetryAfterMs(error.headers?.get("retry-after") ?? null) : void 0;
|
|
2752
|
+
return retryAfter === void 0 ? exponential : Math.min(RETRY_BACKOFF_MAX_MS, Math.max(exponential, retryAfter));
|
|
2753
|
+
}
|
|
2754
|
+
function buildUrl(baseUrl, config) {
|
|
2755
|
+
if (!config.url?.startsWith("/v1/")) throw new HevyHttpError("Invalid Hevy API endpoint", {
|
|
2756
|
+
method: config.method ?? "GET",
|
|
2757
|
+
endpoint: "unknown",
|
|
2758
|
+
code: "HEVY_INVALID_ENDPOINT"
|
|
2759
|
+
});
|
|
2760
|
+
const url = new URL(config.url, baseUrl);
|
|
2761
|
+
if (config.params && typeof config.params === "object") {
|
|
2762
|
+
for (const [key, value] of Object.entries(config.params)) if (value !== void 0) url.searchParams.append(key, value === null ? "null" : String(value));
|
|
2763
|
+
}
|
|
2764
|
+
return url;
|
|
2765
|
+
}
|
|
2766
|
+
async function parseResponseData(response) {
|
|
2767
|
+
if ([
|
|
2768
|
+
204,
|
|
2769
|
+
205,
|
|
2770
|
+
304
|
|
2771
|
+
].includes(response.status) || !response.body) return {};
|
|
2772
|
+
const text = await response.text();
|
|
2773
|
+
if (!text) return {};
|
|
2774
|
+
try {
|
|
2775
|
+
return JSON.parse(text);
|
|
2776
|
+
} catch {
|
|
2777
|
+
return text;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
function getNetworkCode(error) {
|
|
2781
|
+
return error instanceof DOMException && error.name === "AbortError" ? "ETIMEDOUT" : "ERR_NETWORK";
|
|
2782
|
+
}
|
|
2783
|
+
function isRetryable(error) {
|
|
2784
|
+
if (error.code === "HEVY_REQUEST_ABORTED") return false;
|
|
2785
|
+
return error.status === void 0 || RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500 && error.status <= 599;
|
|
2786
|
+
}
|
|
2787
|
+
function createNativeClient(apiKey, baseUrl, options) {
|
|
2788
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
2789
|
+
const maxGetRetries = normalizeMaxGetRetries(options.maxGetRetries);
|
|
2790
|
+
const timeoutMs = normalizePositiveInteger(options.timeoutMs, DEFAULT_API_TIMEOUT_MS);
|
|
2791
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
2792
|
+
let clientConfig = { baseURL: baseUrl };
|
|
2793
|
+
const client = (async (config) => {
|
|
2794
|
+
const normalized = {
|
|
2795
|
+
...clientConfig,
|
|
2796
|
+
...config
|
|
2797
|
+
};
|
|
2798
|
+
const { method, endpoint } = getRequestContext(normalized);
|
|
2799
|
+
const url = buildUrl(baseUrl, normalized);
|
|
2800
|
+
let retryCount = 0;
|
|
2801
|
+
while (true) {
|
|
2802
|
+
if (normalized.signal?.aborted) throw new HevyHttpError("Hevy API request was canceled", {
|
|
2803
|
+
method,
|
|
2804
|
+
endpoint,
|
|
2805
|
+
code: HEVY_REQUEST_ABORTED_ERROR_CODE
|
|
2989
2806
|
});
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
2807
|
+
const startedAt = Date.now();
|
|
2808
|
+
const controller = new AbortController();
|
|
2809
|
+
const abortFromCaller = () => controller.abort();
|
|
2810
|
+
normalized.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
2811
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
2812
|
+
try {
|
|
2813
|
+
const headers = new Headers({ "api-key": apiKey });
|
|
2814
|
+
if (normalized.data !== void 0 && !(normalized.data instanceof FormData)) headers.set("content-type", "application/json");
|
|
2815
|
+
const response = await fetchImplementation(url, {
|
|
2816
|
+
method,
|
|
2817
|
+
headers,
|
|
2818
|
+
redirect: "manual",
|
|
2819
|
+
body: normalized.data instanceof FormData ? normalized.data : normalized.data === void 0 ? void 0 : JSON.stringify(normalized.data),
|
|
2820
|
+
signal: controller.signal
|
|
2821
|
+
});
|
|
2822
|
+
const data = await parseResponseData(response);
|
|
2823
|
+
if (!response.ok) throw new HevyHttpError(`Hevy API request failed (HTTP ${response.status})`, {
|
|
2824
|
+
status: response.status,
|
|
2825
|
+
statusText: response.statusText,
|
|
2826
|
+
data,
|
|
2827
|
+
headers: response.headers,
|
|
3001
2828
|
method,
|
|
3002
2829
|
endpoint
|
|
2830
|
+
});
|
|
2831
|
+
options.onRequestComplete?.({
|
|
2832
|
+
method,
|
|
2833
|
+
endpoint,
|
|
2834
|
+
status: response.status,
|
|
2835
|
+
durationMs: Date.now() - startedAt
|
|
2836
|
+
});
|
|
2837
|
+
return {
|
|
2838
|
+
data,
|
|
2839
|
+
status: response.status,
|
|
2840
|
+
statusText: response.statusText,
|
|
2841
|
+
headers: response.headers
|
|
2842
|
+
};
|
|
2843
|
+
} catch (cause) {
|
|
2844
|
+
const error = isHevyHttpError(cause) ? cause : new HevyHttpError(normalized.signal?.aborted ? "Hevy API request was canceled" : "Hevy API network request failed", {
|
|
2845
|
+
method,
|
|
2846
|
+
endpoint,
|
|
2847
|
+
code: normalized.signal?.aborted ? HEVY_REQUEST_ABORTED_ERROR_CODE : getNetworkCode(cause),
|
|
2848
|
+
cause
|
|
2849
|
+
});
|
|
2850
|
+
options.onRequestComplete?.({
|
|
2851
|
+
method,
|
|
2852
|
+
endpoint,
|
|
2853
|
+
status: error.status ?? 0,
|
|
2854
|
+
durationMs: Date.now() - startedAt,
|
|
2855
|
+
error
|
|
2856
|
+
});
|
|
2857
|
+
if (!(method === "GET" && isRetryable(error))) {
|
|
2858
|
+
emitClientLog(options.logger, {
|
|
2859
|
+
level: "error",
|
|
2860
|
+
logger: "hevy-api",
|
|
2861
|
+
data: {
|
|
2862
|
+
message: "Hevy API request failed",
|
|
2863
|
+
status: error.status ?? null,
|
|
2864
|
+
method,
|
|
2865
|
+
endpoint
|
|
2866
|
+
}
|
|
2867
|
+
});
|
|
2868
|
+
throw error;
|
|
3003
2869
|
}
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
2870
|
+
if (retryCount >= maxGetRetries) {
|
|
2871
|
+
error.hevyRetryExhausted = true;
|
|
2872
|
+
error.hevyRetryCount = retryCount;
|
|
2873
|
+
error.code = HEVY_RETRY_EXHAUSTED_ERROR_CODE;
|
|
2874
|
+
throw error;
|
|
2875
|
+
}
|
|
2876
|
+
retryCount += 1;
|
|
2877
|
+
const delayMs = getRetryDelayMs(error, retryCount);
|
|
2878
|
+
emitClientLog(options.logger, {
|
|
2879
|
+
level: error.status === 429 ? "warning" : "debug",
|
|
2880
|
+
logger: "hevy-api",
|
|
2881
|
+
data: {
|
|
2882
|
+
message: "Retrying Hevy API request",
|
|
2883
|
+
status: error.status ?? null,
|
|
2884
|
+
attempt: retryCount + 1,
|
|
2885
|
+
maxAttempts: maxGetRetries + 1,
|
|
2886
|
+
delayMs,
|
|
2887
|
+
method,
|
|
2888
|
+
endpoint
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
await sleep(delayMs);
|
|
2892
|
+
} finally {
|
|
2893
|
+
clearTimeout(timeout);
|
|
2894
|
+
normalized.signal?.removeEventListener("abort", abortFromCaller);
|
|
3023
2895
|
}
|
|
3024
|
-
}
|
|
3025
|
-
await sleep(retryDelayMs);
|
|
3026
|
-
}
|
|
3027
|
-
}
|
|
3028
|
-
function createResilientClient(axiosInstance, maxGetRetries, logger) {
|
|
3029
|
-
let clientConfig = { baseURL: axiosInstance.defaults.baseURL };
|
|
3030
|
-
const resilientClient = (async (config) => {
|
|
3031
|
-
return requestWithRetries(axiosInstance, config, maxGetRetries, logger);
|
|
2896
|
+
}
|
|
3032
2897
|
});
|
|
3033
|
-
|
|
3034
|
-
|
|
2898
|
+
client.getConfig = () => ({ ...clientConfig });
|
|
2899
|
+
client.setConfig = (config) => {
|
|
3035
2900
|
clientConfig = {
|
|
3036
2901
|
...clientConfig,
|
|
3037
|
-
...config
|
|
2902
|
+
...config,
|
|
2903
|
+
baseURL: baseUrl
|
|
3038
2904
|
};
|
|
3039
|
-
|
|
3040
|
-
return resilientClient.getConfig();
|
|
2905
|
+
return client.getConfig();
|
|
3041
2906
|
};
|
|
3042
|
-
return
|
|
3043
|
-
}
|
|
3044
|
-
/**
|
|
3045
|
-
* Extract common request metadata from an Axios config.
|
|
3046
|
-
* Shared between the success and error response interceptors to avoid
|
|
3047
|
-
* duplicating the method/url/endpoint/duration extraction logic.
|
|
3048
|
-
*/
|
|
3049
|
-
function extractRequestMeta(config) {
|
|
3050
|
-
const method = (config.method ?? "get").toUpperCase();
|
|
3051
|
-
const url = config.url ?? "";
|
|
3052
|
-
const endpoint = url.split("?")[0] ?? url;
|
|
3053
|
-
const now = Date.now();
|
|
3054
|
-
return {
|
|
3055
|
-
method,
|
|
3056
|
-
url,
|
|
3057
|
-
endpoint,
|
|
3058
|
-
durationMs: now - (config._startTime ?? now)
|
|
3059
|
-
};
|
|
3060
|
-
}
|
|
3061
|
-
/**
|
|
3062
|
-
* Finalize a traced span and record metrics for a completed HTTP request.
|
|
3063
|
-
*/
|
|
3064
|
-
function finalizeRequestTrace(opts) {
|
|
3065
|
-
const { span, statusCode, durationMs, method, endpoint, error } = opts;
|
|
3066
|
-
if (span) {
|
|
3067
|
-
span.setAttribute("http.status_code", statusCode);
|
|
3068
|
-
span.setAttribute("http.response.duration_ms", durationMs);
|
|
3069
|
-
if (error) {
|
|
3070
|
-
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
3071
|
-
span.recordException(error);
|
|
3072
|
-
} else span.setStatus({ code: SpanStatusCode.OK });
|
|
3073
|
-
span.end();
|
|
3074
|
-
}
|
|
3075
|
-
apiCalls.add(1, {
|
|
3076
|
-
method,
|
|
3077
|
-
endpoint,
|
|
3078
|
-
status_code: statusCode
|
|
3079
|
-
});
|
|
3080
|
-
apiDuration.record(durationMs, {
|
|
3081
|
-
method,
|
|
3082
|
-
endpoint
|
|
3083
|
-
});
|
|
2907
|
+
return client;
|
|
3084
2908
|
}
|
|
3085
2909
|
function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com", options = {}) {
|
|
3086
|
-
const { logger } = options;
|
|
3087
|
-
const maxGetRetries = normalizeMaxGetRetries(options.maxGetRetries);
|
|
3088
|
-
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
3089
|
-
const axiosInstance = axios.create({
|
|
3090
|
-
baseURL: baseUrl,
|
|
3091
|
-
timeout: timeoutMs,
|
|
3092
|
-
headers: { "api-key": apiKey }
|
|
3093
|
-
});
|
|
3094
|
-
axiosInstance.interceptors.request.use((config) => {
|
|
3095
|
-
const tracedConfig = config;
|
|
3096
|
-
const method = (config.method ?? "get").toUpperCase();
|
|
3097
|
-
const url = config.url ?? "";
|
|
3098
|
-
const endpoint = url.split("?")[0] ?? url;
|
|
3099
|
-
const userId = getCurrentUserId();
|
|
3100
|
-
tracedConfig._span = tracer.startSpan(`hevy.api.${method}`, { attributes: {
|
|
3101
|
-
"http.method": method,
|
|
3102
|
-
"http.url": url,
|
|
3103
|
-
"http.base_url": config.baseURL ?? "",
|
|
3104
|
-
"hevy.api.endpoint": endpoint,
|
|
3105
|
-
...userId ? { "user.id": userId } : {}
|
|
3106
|
-
} });
|
|
3107
|
-
tracedConfig._startTime = Date.now();
|
|
3108
|
-
return config;
|
|
3109
|
-
});
|
|
3110
|
-
axiosInstance.interceptors.response.use((response) => {
|
|
3111
|
-
const tracedConfig = response.config;
|
|
3112
|
-
const { method, endpoint, durationMs } = extractRequestMeta({
|
|
3113
|
-
method: response.config.method,
|
|
3114
|
-
url: response.config.url,
|
|
3115
|
-
_startTime: tracedConfig._startTime
|
|
3116
|
-
});
|
|
3117
|
-
const requestContext = getRequestContext(response.config);
|
|
3118
|
-
debugLog("api_response", {
|
|
3119
|
-
method: requestContext.method,
|
|
3120
|
-
endpoint: requestContext.endpoint,
|
|
3121
|
-
durationMs,
|
|
3122
|
-
status: response.status
|
|
3123
|
-
});
|
|
3124
|
-
finalizeRequestTrace({
|
|
3125
|
-
span: tracedConfig._span,
|
|
3126
|
-
statusCode: response.status,
|
|
3127
|
-
durationMs,
|
|
3128
|
-
method,
|
|
3129
|
-
endpoint
|
|
3130
|
-
});
|
|
3131
|
-
return response;
|
|
3132
|
-
}, (error) => {
|
|
3133
|
-
const tracedConfig = error.config ?? {};
|
|
3134
|
-
const { method, endpoint, durationMs } = extractRequestMeta({
|
|
3135
|
-
method: error.config?.method,
|
|
3136
|
-
url: error.config?.url,
|
|
3137
|
-
_startTime: tracedConfig._startTime
|
|
3138
|
-
});
|
|
3139
|
-
const requestContext = getRequestContext(error.config ?? {});
|
|
3140
|
-
debugLog("api_response", {
|
|
3141
|
-
method: requestContext.method,
|
|
3142
|
-
endpoint: requestContext.endpoint,
|
|
3143
|
-
durationMs,
|
|
3144
|
-
status: error.response?.status ?? null
|
|
3145
|
-
});
|
|
3146
|
-
finalizeRequestTrace({
|
|
3147
|
-
span: tracedConfig._span,
|
|
3148
|
-
statusCode: error.response?.status ?? 0,
|
|
3149
|
-
durationMs,
|
|
3150
|
-
method,
|
|
3151
|
-
endpoint,
|
|
3152
|
-
error
|
|
3153
|
-
});
|
|
3154
|
-
throw error;
|
|
3155
|
-
});
|
|
3156
2910
|
const headers = { "api-key": apiKey };
|
|
3157
|
-
const client =
|
|
2911
|
+
const client = createNativeClient(apiKey, baseUrl, options);
|
|
3158
2912
|
return {
|
|
3159
2913
|
getWorkouts: (params) => wrapApi(getV1Workouts)(headers, params, { client }),
|
|
3160
2914
|
getWorkout: (workoutId) => wrapApi(getV1WorkoutsWorkoutid)(workoutId, headers, { client }),
|
|
@@ -3177,7 +2931,10 @@ function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com", options = {
|
|
|
3177
2931
|
getBodyMeasurement: (date) => wrapApi(getV1BodyMeasurementsDate)(date, headers, { client }),
|
|
3178
2932
|
createBodyMeasurement: (data) => wrapApi(postV1BodyMeasurements)(data, headers, { client }),
|
|
3179
2933
|
updateBodyMeasurement: (date, data) => wrapApi(putV1BodyMeasurementsDate)(date, data, headers, { client }),
|
|
3180
|
-
getUserInfo: () => wrapApi(getV1UserInfo)(headers, {
|
|
2934
|
+
getUserInfo: (config = {}) => wrapApi(getV1UserInfo)(headers, {
|
|
2935
|
+
...config,
|
|
2936
|
+
client
|
|
2937
|
+
})
|
|
3181
2938
|
};
|
|
3182
2939
|
}
|
|
3183
2940
|
//#endregion
|
|
@@ -3201,50 +2958,430 @@ function createMcpClientLogger(server) {
|
|
|
3201
2958
|
return;
|
|
3202
2959
|
}
|
|
3203
2960
|
server.sendLoggingMessage(message).catch((error) => {
|
|
3204
|
-
console.error(SEND_FAILURE_MESSAGE, error);
|
|
2961
|
+
console.error(SEND_FAILURE_MESSAGE, createSafeErrorDiagnostic(error));
|
|
3205
2962
|
});
|
|
3206
2963
|
} catch (error) {
|
|
3207
|
-
console.error(SEND_FAILURE_MESSAGE, error);
|
|
2964
|
+
console.error(SEND_FAILURE_MESSAGE, createSafeErrorDiagnostic(error));
|
|
3208
2965
|
}
|
|
3209
2966
|
};
|
|
3210
2967
|
}
|
|
3211
2968
|
//#endregion
|
|
3212
|
-
//#region src/
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
const STDIN_PARSE_SHAPE_PREVIEW_MAX_LENGTH = 200;
|
|
3216
|
-
const REDACTED_CONTENT_MARKER = "[REDACTED]";
|
|
3217
|
-
/**
|
|
3218
|
-
* Adapter boundary around MCP SDK stdio internals.
|
|
3219
|
-
*
|
|
3220
|
-
* MCP SDK v1.29.0 exposes public message-level hooks but does not expose a
|
|
3221
|
-
* public raw-chunk hook on `StdioServerTransport`. To capture chunk metadata,
|
|
3222
|
-
* we currently rely on private internals (`_ondata`, `_readBuffer`, `_buffer`)
|
|
3223
|
-
* in this one place.
|
|
3224
|
-
*
|
|
3225
|
-
* If those internals change in a future SDK release, this adapter should fail
|
|
3226
|
-
* closed and preserve default transport behavior (no instrumentation patching).
|
|
3227
|
-
*/
|
|
3228
|
-
function createSdkPrivateStdioAdapter(transport) {
|
|
3229
|
-
const mutableTransport = transport;
|
|
2969
|
+
//#region src/shared-server.ts
|
|
2970
|
+
function createToolCountingServer(server) {
|
|
2971
|
+
let count = 0;
|
|
3230
2972
|
return {
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
originalOnData(chunk);
|
|
2973
|
+
server: new Proxy(server, { get(target, property, receiver) {
|
|
2974
|
+
if (property === "tool") return (...args) => {
|
|
2975
|
+
const result = target.tool(...args);
|
|
2976
|
+
count += 1;
|
|
2977
|
+
return result;
|
|
3237
2978
|
};
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
2979
|
+
if (property === "registerTool") {
|
|
2980
|
+
const registerTool = (name, config, callback) => {
|
|
2981
|
+
const result = target.registerTool(name, config, callback);
|
|
2982
|
+
count += 1;
|
|
2983
|
+
return result;
|
|
2984
|
+
};
|
|
2985
|
+
return registerTool;
|
|
2986
|
+
}
|
|
2987
|
+
return Reflect.get(target, property, receiver);
|
|
2988
|
+
} }),
|
|
2989
|
+
getCount: () => count
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
/** Construct and register a complete MCP server without importing Node-only code. */
|
|
2993
|
+
function createSharedMcpServer(options) {
|
|
2994
|
+
const baseServer = new McpServer({
|
|
2995
|
+
name: SERVER_NAME,
|
|
2996
|
+
version: SERVER_VERSION
|
|
2997
|
+
}, {
|
|
2998
|
+
capabilities: { logging: {} },
|
|
2999
|
+
instructions: SERVER_INSTRUCTIONS
|
|
3000
|
+
});
|
|
3001
|
+
const server = options.wrapServer?.(baseServer) ?? baseServer;
|
|
3002
|
+
const logger = createMcpClientLogger(server);
|
|
3003
|
+
const hevyClient = options.hevyClient ?? createClient(options.apiKey, "https://api.hevyapp.com", {
|
|
3004
|
+
...options.clientOptions,
|
|
3005
|
+
logger
|
|
3006
|
+
});
|
|
3007
|
+
const wrapHandler = options.wrapHandler ?? withErrorHandling;
|
|
3008
|
+
const catalog = createExerciseTemplateCatalog();
|
|
3009
|
+
const counting = createToolCountingServer(server);
|
|
3010
|
+
registerHevyTools(counting.server, hevyClient, {
|
|
3011
|
+
catalog,
|
|
3012
|
+
logger,
|
|
3013
|
+
wrapHandler
|
|
3014
|
+
});
|
|
3015
|
+
options.onToolsRegistered?.(counting.getCount());
|
|
3016
|
+
registerWorkoutPrompts(server);
|
|
3017
|
+
registerHevyResources(server, hevyClient, catalog);
|
|
3018
|
+
return server;
|
|
3019
|
+
}
|
|
3020
|
+
//#endregion
|
|
3021
|
+
//#region src/utils/config.ts
|
|
3022
|
+
function parseConfig(env) {
|
|
3023
|
+
return { apiKey: env.HEVY_API_KEY || "" };
|
|
3024
|
+
}
|
|
3025
|
+
function assertApiKey(apiKey) {
|
|
3026
|
+
if (!apiKey) {
|
|
3027
|
+
console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable.");
|
|
3028
|
+
process.exit(1);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
//#endregion
|
|
3032
|
+
//#region src/utils/graceful-shutdown.ts
|
|
3033
|
+
const shutdownSignals = ["SIGINT", "SIGTERM"];
|
|
3034
|
+
const FORCED_EXIT_TIMEOUT_MS = 5e3;
|
|
3035
|
+
function flushStdout(stdout = process.stdout) {
|
|
3036
|
+
return new Promise((resolve, reject) => {
|
|
3037
|
+
try {
|
|
3038
|
+
stdout.write("", (error) => {
|
|
3039
|
+
if (error) {
|
|
3040
|
+
reject(error);
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
resolve();
|
|
3044
|
+
});
|
|
3045
|
+
} catch (error) {
|
|
3046
|
+
reject(error);
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
function installGracefulShutdown({ target, process: processLike = process, logError = console.error, flush = flushStdout, forcedExitTimeoutMs = FORCED_EXIT_TIMEOUT_MS, scheduleForcedExit = setTimeout }) {
|
|
3051
|
+
let listenersInstalled = true;
|
|
3052
|
+
let shutdownSettled = false;
|
|
3053
|
+
let shutdownPromise;
|
|
3054
|
+
const cleanup = () => {
|
|
3055
|
+
if (!listenersInstalled || shutdownPromise && !shutdownSettled) return;
|
|
3056
|
+
listenersInstalled = false;
|
|
3057
|
+
for (const signal of shutdownSignals) {
|
|
3058
|
+
const listener = signalListeners.get(signal);
|
|
3059
|
+
if (listener) processLike.removeListener(signal, listener);
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
const handleSignal = (signal) => {
|
|
3063
|
+
if (shutdownPromise) return;
|
|
3064
|
+
if (processLike.exitCode == null) processLike.exitCode = 0;
|
|
3065
|
+
scheduleForcedExit(() => {
|
|
3066
|
+
processLike.exit(processLike.exitCode ?? 0);
|
|
3067
|
+
}, forcedExitTimeoutMs).unref();
|
|
3068
|
+
shutdownPromise = (async () => {
|
|
3069
|
+
logError(`Shutting down gracefully after ${signal}`);
|
|
3070
|
+
let shutdownFailed = false;
|
|
3071
|
+
let shutdownError;
|
|
3072
|
+
try {
|
|
3073
|
+
await target.close();
|
|
3074
|
+
} catch (error) {
|
|
3075
|
+
shutdownFailed = true;
|
|
3076
|
+
shutdownError = error;
|
|
3077
|
+
}
|
|
3078
|
+
try {
|
|
3079
|
+
await flush();
|
|
3080
|
+
} catch (error) {
|
|
3081
|
+
if (!shutdownFailed) shutdownError = error;
|
|
3082
|
+
shutdownFailed = true;
|
|
3083
|
+
}
|
|
3084
|
+
try {
|
|
3085
|
+
if (shutdownFailed) {
|
|
3086
|
+
logError(`Graceful shutdown failed: ${shutdownError instanceof Error ? shutdownError.message : "Unknown shutdown error"}`);
|
|
3087
|
+
processLike.exitCode = 1;
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
} finally {
|
|
3091
|
+
shutdownSettled = true;
|
|
3092
|
+
cleanup();
|
|
3093
|
+
}
|
|
3094
|
+
})();
|
|
3095
|
+
};
|
|
3096
|
+
const signalListeners = new Map(shutdownSignals.map((signal) => [signal, () => handleSignal(signal)]));
|
|
3097
|
+
for (const signal of shutdownSignals) {
|
|
3098
|
+
const listener = signalListeners.get(signal);
|
|
3099
|
+
if (listener) processLike.on(signal, listener);
|
|
3100
|
+
}
|
|
3101
|
+
return {
|
|
3102
|
+
cleanup,
|
|
3103
|
+
getShutdownPromise: () => shutdownPromise
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
//#endregion
|
|
3107
|
+
//#region src/utils/debug.ts
|
|
3108
|
+
const DEBUG_PREFIX = "[hevy-mcp:debug] ";
|
|
3109
|
+
const MAX_DEBUG_RECORD_LENGTH = 8192;
|
|
3110
|
+
const MAX_REDACTION_DEPTH = 4;
|
|
3111
|
+
const MAX_OBJECT_KEYS = 20;
|
|
3112
|
+
function isDebugEnabled(env = process.env) {
|
|
3113
|
+
return env.HEVY_MCP_DEBUG === "1";
|
|
3114
|
+
}
|
|
3115
|
+
function redactValue(value, depth, seen) {
|
|
3116
|
+
if (value === null) return "[null]";
|
|
3117
|
+
if (typeof value !== "object") return `[${typeof value}]`;
|
|
3118
|
+
if (seen.has(value)) return "[circular]";
|
|
3119
|
+
if (Array.isArray(value)) {
|
|
3120
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
3121
|
+
const length = lengthDescriptor && "value" in lengthDescriptor && typeof lengthDescriptor.value === "number" ? lengthDescriptor.value : 0;
|
|
3122
|
+
if (depth >= MAX_REDACTION_DEPTH) return {
|
|
3123
|
+
type: "array",
|
|
3124
|
+
length,
|
|
3125
|
+
items: "[max-depth]"
|
|
3126
|
+
};
|
|
3127
|
+
seen.add(value);
|
|
3128
|
+
try {
|
|
3129
|
+
const itemCount = Math.min(length, MAX_OBJECT_KEYS);
|
|
3130
|
+
const items = {};
|
|
3131
|
+
for (let index = 0; index < itemCount; index += 1) {
|
|
3132
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
3133
|
+
items[`item-${index + 1}`] = descriptor && "value" in descriptor ? redactValue(descriptor.value, depth + 1, seen) : "[empty-or-accessor]";
|
|
3134
|
+
}
|
|
3135
|
+
return {
|
|
3136
|
+
type: "array",
|
|
3137
|
+
length,
|
|
3138
|
+
items,
|
|
3139
|
+
...length > MAX_OBJECT_KEYS ? { truncatedItems: length - MAX_OBJECT_KEYS } : {}
|
|
3140
|
+
};
|
|
3141
|
+
} finally {
|
|
3142
|
+
seen.delete(value);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
const keys = Object.keys(value);
|
|
3146
|
+
if (depth >= MAX_REDACTION_DEPTH) return {
|
|
3147
|
+
type: "object",
|
|
3148
|
+
fieldCount: keys.length,
|
|
3149
|
+
fields: "[max-depth]"
|
|
3150
|
+
};
|
|
3151
|
+
seen.add(value);
|
|
3152
|
+
try {
|
|
3153
|
+
const fields = {};
|
|
3154
|
+
for (const [index, rawKey] of keys.slice(0, MAX_OBJECT_KEYS).entries()) {
|
|
3155
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, rawKey);
|
|
3156
|
+
fields[`field-${index + 1}`] = descriptor && "value" in descriptor ? redactValue(descriptor.value, depth + 1, seen) : "[accessor]";
|
|
3157
|
+
}
|
|
3158
|
+
return {
|
|
3159
|
+
type: "object",
|
|
3160
|
+
fieldCount: keys.length,
|
|
3161
|
+
fields,
|
|
3162
|
+
...keys.length > MAX_OBJECT_KEYS ? { truncatedFields: keys.length - MAX_OBJECT_KEYS } : {}
|
|
3163
|
+
};
|
|
3164
|
+
} finally {
|
|
3165
|
+
seen.delete(value);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
/** Redact every input scalar while preserving bounded argument structure. */
|
|
3169
|
+
function redactToolArgs(args) {
|
|
3170
|
+
try {
|
|
3171
|
+
return redactValue(args, 0, /* @__PURE__ */ new WeakSet());
|
|
3172
|
+
} catch {
|
|
3173
|
+
return "[unavailable]";
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Write a single bounded structured debug record to stderr without throwing.
|
|
3178
|
+
*/
|
|
3179
|
+
function debugLog(event, data) {
|
|
3180
|
+
if (!isDebugEnabled()) return;
|
|
3181
|
+
try {
|
|
3182
|
+
const record = JSON.stringify({
|
|
3183
|
+
event,
|
|
3184
|
+
...data
|
|
3185
|
+
});
|
|
3186
|
+
const output = record.length <= MAX_DEBUG_RECORD_LENGTH ? record : JSON.stringify({
|
|
3187
|
+
event,
|
|
3188
|
+
truncated: true
|
|
3189
|
+
});
|
|
3190
|
+
process.stderr.write(`${DEBUG_PREFIX}${output}\n`);
|
|
3191
|
+
} catch {}
|
|
3192
|
+
}
|
|
3193
|
+
//#endregion
|
|
3194
|
+
//#region src/utils/hevy-client-observability.ts
|
|
3195
|
+
/** Node-only adapter; the Worker graph never imports telemetry or metrics. */
|
|
3196
|
+
function createNodeHevyClientOptions() {
|
|
3197
|
+
const rawTimeout = process.env.HEVY_MCP_API_TIMEOUT;
|
|
3198
|
+
const parsedTimeout = rawTimeout ? Number(rawTimeout) : NaN;
|
|
3199
|
+
const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? Math.floor(parsedTimeout) : void 0;
|
|
3200
|
+
return {
|
|
3201
|
+
...timeoutMs ? { timeoutMs } : {},
|
|
3202
|
+
onRequestComplete(observation) {
|
|
3203
|
+
const span = tracer.startSpan(`hevy.api.${observation.method}`, { attributes: {
|
|
3204
|
+
"http.method": observation.method,
|
|
3205
|
+
"http.status_code": observation.status,
|
|
3206
|
+
"hevy.api.endpoint": observation.endpoint,
|
|
3207
|
+
...getCurrentUserId() ? { "user.id": getCurrentUserId() } : {}
|
|
3208
|
+
} });
|
|
3209
|
+
span.setStatus({ code: observation.error ? SpanStatusCode.ERROR : SpanStatusCode.OK });
|
|
3210
|
+
if (observation.error) {
|
|
3211
|
+
const diagnostic = createSafeErrorDiagnostic(observation.error);
|
|
3212
|
+
span.addEvent("hevy.api.failure", {
|
|
3213
|
+
"error.category": diagnostic.category,
|
|
3214
|
+
...diagnostic.code ? { "error.code": diagnostic.code } : {}
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
3217
|
+
span.end();
|
|
3218
|
+
apiCalls.add(1, {
|
|
3219
|
+
method: observation.method,
|
|
3220
|
+
endpoint: observation.endpoint,
|
|
3221
|
+
status_code: observation.status
|
|
3222
|
+
});
|
|
3223
|
+
apiDuration.record(observation.durationMs, {
|
|
3224
|
+
method: observation.method,
|
|
3225
|
+
endpoint: observation.endpoint
|
|
3226
|
+
});
|
|
3227
|
+
debugLog("api_response", {
|
|
3228
|
+
method: observation.method,
|
|
3229
|
+
endpoint: observation.endpoint,
|
|
3230
|
+
durationMs: observation.durationMs,
|
|
3231
|
+
status: observation.status || null
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
//#endregion
|
|
3237
|
+
//#region src/utils/telemetry-wrapper.ts
|
|
3238
|
+
/** Whitelist of safe argument keys that can be logged without exposing PII. */
|
|
3239
|
+
const ARGUMENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
3240
|
+
"page",
|
|
3241
|
+
"pageSize",
|
|
3242
|
+
"since",
|
|
3243
|
+
"workoutId",
|
|
3244
|
+
"routineId",
|
|
3245
|
+
"folderId",
|
|
3246
|
+
"exerciseTemplateId",
|
|
3247
|
+
"date",
|
|
3248
|
+
"startDate",
|
|
3249
|
+
"endDate",
|
|
3250
|
+
"updatedSince",
|
|
3251
|
+
"includeCustom",
|
|
3252
|
+
"limit",
|
|
3253
|
+
"offset",
|
|
3254
|
+
"refresh",
|
|
3255
|
+
"query",
|
|
3256
|
+
"primaryMuscleGroup"
|
|
3257
|
+
]);
|
|
3258
|
+
function extractSafeArgs(args) {
|
|
3259
|
+
const attributes = {};
|
|
3260
|
+
for (const [key, value] of Object.entries(args)) {
|
|
3261
|
+
if (!ARGUMENT_WHITELIST.has(key)) continue;
|
|
3262
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue;
|
|
3263
|
+
attributes[`mcp.tool.args.${key}`] = key === "query" && typeof value === "string" && value.length > 100 ? `${value.slice(0, 100)}...` : value;
|
|
3264
|
+
}
|
|
3265
|
+
return attributes;
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* Wrap an MCP tool handler with its existing OpenTelemetry span and metrics.
|
|
3269
|
+
*/
|
|
3270
|
+
function withTelemetry(fn, context) {
|
|
3271
|
+
return async (rawArgs) => {
|
|
3272
|
+
const args = rawArgs ?? {};
|
|
3273
|
+
if (isDebugEnabled()) debugLog("tool_invocation", {
|
|
3274
|
+
tool: context,
|
|
3275
|
+
params: redactToolArgs(args)
|
|
3276
|
+
});
|
|
3277
|
+
const argumentKeyCount = Object.keys(args).length;
|
|
3278
|
+
const startTime = Date.now();
|
|
3279
|
+
let isError = false;
|
|
3280
|
+
toolInvocations.add(1, { tool_name: context });
|
|
3281
|
+
const userId = getCurrentUserId();
|
|
3282
|
+
const safeArgs = extractSafeArgs(args);
|
|
3283
|
+
const whitelistedKeys = Object.keys(safeArgs).map((key) => key.replace("mcp.tool.args.", ""));
|
|
3284
|
+
return tracer.startActiveSpan(`mcp.tool.${context}`, { attributes: {
|
|
3285
|
+
"mcp.tool.name": context,
|
|
3286
|
+
"mcp.tool.args.key_count": argumentKeyCount,
|
|
3287
|
+
"mcp.tool.args.keys": whitelistedKeys.join(","),
|
|
3288
|
+
...userId ? { "user.id": userId } : {},
|
|
3289
|
+
...safeArgs
|
|
3290
|
+
} }, async (span) => {
|
|
3291
|
+
try {
|
|
3292
|
+
const result = await fn(args);
|
|
3293
|
+
isError = Boolean(result.isError);
|
|
3294
|
+
span.setStatus({ code: isError ? SpanStatusCode.ERROR : SpanStatusCode.OK });
|
|
3295
|
+
span.setAttribute("mcp.tool.result.is_error", isError);
|
|
3296
|
+
if (result.content) {
|
|
3297
|
+
span.setAttribute("mcp.tool.result.content_count", result.content.length);
|
|
3298
|
+
const textLength = result.content.reduce((sum, item) => sum + (item.text?.length ?? 0), 0);
|
|
3299
|
+
span.setAttribute("mcp.tool.result.text_length", textLength);
|
|
3300
|
+
}
|
|
3301
|
+
return result;
|
|
3302
|
+
} catch (error) {
|
|
3303
|
+
isError = true;
|
|
3304
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
3305
|
+
const diagnostic = createSafeErrorDiagnostic(error);
|
|
3306
|
+
span.addEvent("mcp.tool.failure", {
|
|
3307
|
+
"error.category": diagnostic.category,
|
|
3308
|
+
...diagnostic.code ? { "error.code": diagnostic.code } : {},
|
|
3309
|
+
...diagnostic.status !== void 0 ? { "http.status_code": diagnostic.status } : {},
|
|
3310
|
+
...diagnostic.method ? { "http.method": diagnostic.method } : {},
|
|
3311
|
+
...diagnostic.endpoint ? { "hevy.api.endpoint": diagnostic.endpoint } : {}
|
|
3312
|
+
});
|
|
3313
|
+
const errorType = determineErrorType(error, `${diagnostic.category} occurred`);
|
|
3314
|
+
span.setAttribute("error.type", errorType);
|
|
3315
|
+
toolErrors.add(1, {
|
|
3316
|
+
tool_name: context,
|
|
3317
|
+
error_type: errorType
|
|
3318
|
+
});
|
|
3319
|
+
throw error;
|
|
3320
|
+
} finally {
|
|
3321
|
+
toolDuration.record(Date.now() - startTime, {
|
|
3322
|
+
tool_name: context,
|
|
3323
|
+
is_error: String(isError)
|
|
3324
|
+
});
|
|
3325
|
+
span.end();
|
|
3326
|
+
}
|
|
3327
|
+
});
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
//#endregion
|
|
3331
|
+
//#region src/utils/observability-wrapper.ts
|
|
3332
|
+
/**
|
|
3333
|
+
* Wrap an MCP tool handler with telemetry inside error response handling.
|
|
3334
|
+
*/
|
|
3335
|
+
function withObservability(fn, context) {
|
|
3336
|
+
return withErrorHandling(withTelemetry(fn, context), context, (error, _toolContext, argumentKeyCount) => {
|
|
3337
|
+
const diagnostic = createSafeErrorDiagnostic(error);
|
|
3338
|
+
Sentry.withScope((scope) => {
|
|
3339
|
+
scope.setTag("error.category", diagnostic.category);
|
|
3340
|
+
if (diagnostic.code) scope.setTag("error.code", diagnostic.code);
|
|
3341
|
+
if (diagnostic.status !== void 0) scope.setTag("http.status_code", String(diagnostic.status));
|
|
3342
|
+
scope.setContext("mcpTool", { argumentKeyCount });
|
|
3343
|
+
scope.setContext("safeError", { ...diagnostic });
|
|
3344
|
+
Sentry.captureMessage("MCP tool failure", "error");
|
|
3345
|
+
});
|
|
3346
|
+
});
|
|
3347
|
+
}
|
|
3348
|
+
//#endregion
|
|
3349
|
+
//#region src/utils/stdio-observability.ts
|
|
3350
|
+
const UTF8_BOM = "";
|
|
3351
|
+
/** Maximum escaped characters included in a malformed stdin shape preview. */
|
|
3352
|
+
const STDIN_PARSE_SHAPE_PREVIEW_MAX_LENGTH = 200;
|
|
3353
|
+
const REDACTED_CONTENT_MARKER = "[REDACTED]";
|
|
3354
|
+
/**
|
|
3355
|
+
* Adapter boundary around MCP SDK stdio internals.
|
|
3356
|
+
*
|
|
3357
|
+
* MCP SDK v1.29.0 exposes public message-level hooks but does not expose a
|
|
3358
|
+
* public raw-chunk hook on `StdioServerTransport`. To capture chunk metadata,
|
|
3359
|
+
* we currently rely on private internals (`_ondata`, `_readBuffer`, `_buffer`)
|
|
3360
|
+
* in this one place.
|
|
3361
|
+
*
|
|
3362
|
+
* If those internals change in a future SDK release, this adapter should fail
|
|
3363
|
+
* closed and preserve default transport behavior (no instrumentation patching).
|
|
3364
|
+
*/
|
|
3365
|
+
function createSdkPrivateStdioAdapter(transport) {
|
|
3366
|
+
const mutableTransport = transport;
|
|
3367
|
+
return {
|
|
3368
|
+
wrapOnData(onChunk) {
|
|
3369
|
+
const originalOnData = mutableTransport._ondata;
|
|
3370
|
+
if (typeof originalOnData !== "function") return;
|
|
3371
|
+
mutableTransport._ondata = (chunk) => {
|
|
3372
|
+
onChunk(chunk);
|
|
3373
|
+
originalOnData(chunk);
|
|
3374
|
+
};
|
|
3375
|
+
},
|
|
3376
|
+
installReadMessageHook(onReadLine) {
|
|
3377
|
+
const readBuffer = mutableTransport._readBuffer;
|
|
3378
|
+
if (!readBuffer || typeof readBuffer.readMessage !== "function") return false;
|
|
3379
|
+
readBuffer.readMessage = () => {
|
|
3380
|
+
const buffer = readBuffer._buffer;
|
|
3381
|
+
if (!buffer) return null;
|
|
3382
|
+
const index = buffer.indexOf("\n");
|
|
3383
|
+
if (index === -1) return null;
|
|
3384
|
+
const lineBuffer = buffer.subarray(0, index);
|
|
3248
3385
|
readBuffer._buffer = buffer.subarray(index + 1);
|
|
3249
3386
|
return onReadLine(lineBuffer.toString("utf8").replace(/\r$/, ""));
|
|
3250
3387
|
};
|
|
@@ -3343,10 +3480,11 @@ function deserializeMessageWithObservability(line, chunkSnapshot) {
|
|
|
3343
3480
|
if (message && typeof message === "object" && "method" in message) span.setAttribute("mcp.method", String(message.method));
|
|
3344
3481
|
return message;
|
|
3345
3482
|
} catch (error) {
|
|
3483
|
+
const diagnostic = createSafeErrorDiagnostic(error);
|
|
3346
3484
|
const failurePosition = parseFailurePosition(error);
|
|
3347
3485
|
const failureLocation = getFailureLocation(failurePosition, lineHadLeadingBom);
|
|
3348
3486
|
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
3349
|
-
span.
|
|
3487
|
+
span.addEvent("mcp.stdio.parse.failure", { "error.category": diagnostic.category });
|
|
3350
3488
|
span.setAttribute("mcp.stdio.parse.failure.location", failureLocation);
|
|
3351
3489
|
if (failurePosition !== null) span.setAttribute("mcp.stdio.parse.failure.position", failurePosition);
|
|
3352
3490
|
stdioParseErrors.add(1, { failure_location: failureLocation });
|
|
@@ -3363,9 +3501,9 @@ function deserializeMessageWithObservability(line, chunkSnapshot) {
|
|
|
3363
3501
|
failureLocation,
|
|
3364
3502
|
failurePosition,
|
|
3365
3503
|
failureStage: "deserializeMessage",
|
|
3366
|
-
|
|
3504
|
+
errorCategory: diagnostic.category
|
|
3367
3505
|
});
|
|
3368
|
-
Sentry.
|
|
3506
|
+
Sentry.captureMessage("MCP stdin parse failure", "error");
|
|
3369
3507
|
});
|
|
3370
3508
|
reportStdinParseFailure(error, line, lineByteLength, failureLocation, failurePosition);
|
|
3371
3509
|
throw error;
|
|
@@ -3520,34 +3658,6 @@ function scheduleUpdateCheck(options, overrides = {}) {
|
|
|
3520
3658
|
//#region src/index.ts
|
|
3521
3659
|
const name = name$1;
|
|
3522
3660
|
const version = version$1;
|
|
3523
|
-
const SERVER_INSTRUCTIONS = [
|
|
3524
|
-
[
|
|
3525
|
-
"Hevy MCP connects clients to the authenticated user's Hevy",
|
|
3526
|
-
"workout-tracking data, including workouts, routines, exercise templates,",
|
|
3527
|
-
"routine folders, body measurements, and profile information.",
|
|
3528
|
-
"HEVY_API_KEY must contain a valid Hevy API key."
|
|
3529
|
-
].join(" "),
|
|
3530
|
-
[
|
|
3531
|
-
"Safety: all get-* and search-* tools are read-only. create-* and",
|
|
3532
|
-
"update-* tools mutate Hevy data. Creates are additive and",
|
|
3533
|
-
"non-idempotent, so repeating one can create duplicates. Updates can",
|
|
3534
|
-
"overwrite existing data. Delete operations are not available."
|
|
3535
|
-
].join(" "),
|
|
3536
|
-
[
|
|
3537
|
-
"Workflow: search exercise templates first, then use the returned",
|
|
3538
|
-
"template IDs when creating workouts or routines. To create a completed",
|
|
3539
|
-
"workout from a routine, fetch the routine as a plan, then obtain the",
|
|
3540
|
-
"actual completed sets and end time from the user; never invent completion",
|
|
3541
|
-
"data. Use the built-in workflow prompts when they match the task."
|
|
3542
|
-
].join(" "),
|
|
3543
|
-
["Pagination: start at page 1 and fetch only the pages needed. Most list", "tools allow pageSize up to 10; get-exercise-templates allows up to 100."].join(" "),
|
|
3544
|
-
[
|
|
3545
|
-
"Rate limits and retries: minimize repeated calls. If Hevy returns HTTP",
|
|
3546
|
-
"429, follow its retry guidance. Transient read requests retry",
|
|
3547
|
-
"automatically, but write requests do not; confirm uncertain write",
|
|
3548
|
-
"outcomes before trying again."
|
|
3549
|
-
].join(" ")
|
|
3550
|
-
].join("\n\n");
|
|
3551
3661
|
const HELP_TEXT = [
|
|
3552
3662
|
"Usage:",
|
|
3553
3663
|
" hevy-mcp [options]",
|
|
@@ -3555,16 +3665,13 @@ const HELP_TEXT = [
|
|
|
3555
3665
|
"Options:",
|
|
3556
3666
|
" -h, --help Show this help message and exit",
|
|
3557
3667
|
" -v, --version Show version and exit",
|
|
3558
|
-
" --hevy-api-key=<api-key> (deprecated, use HEVY_API_KEY env var)",
|
|
3559
3668
|
"",
|
|
3560
3669
|
"Environment:",
|
|
3561
3670
|
" HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
|
|
3562
3671
|
" HEVY_MCP_DEBUG=1 Enable verbose diagnostics on stderr",
|
|
3563
3672
|
"",
|
|
3564
3673
|
"Examples:",
|
|
3565
|
-
" HEVY_API_KEY=your-key npx hevy-mcp"
|
|
3566
|
-
" npx hevy-mcp --hevy-api-key=your-key",
|
|
3567
|
-
" npm start -- --hevy-api-key=your-key"
|
|
3674
|
+
" HEVY_API_KEY=your-key npx hevy-mcp"
|
|
3568
3675
|
].join("\n");
|
|
3569
3676
|
function getCliAction(args) {
|
|
3570
3677
|
for (const arg of args) {
|
|
@@ -3595,29 +3702,8 @@ function fingerprintApiKey(apiKey) {
|
|
|
3595
3702
|
}
|
|
3596
3703
|
const serverConfigSchema = z.object({ apiKey: z.string().min(1, "Hevy API key is required").describe("Your Hevy API key (available in the Hevy app settings).") });
|
|
3597
3704
|
const configSchema = serverConfigSchema;
|
|
3598
|
-
function createToolCountingServer(server) {
|
|
3599
|
-
let count = 0;
|
|
3600
|
-
return {
|
|
3601
|
-
server: new Proxy(server, { get(target, property, receiver) {
|
|
3602
|
-
if (property === "tool") return (...args) => {
|
|
3603
|
-
const registeredTool = target.tool(...args);
|
|
3604
|
-
count += 1;
|
|
3605
|
-
return registeredTool;
|
|
3606
|
-
};
|
|
3607
|
-
if (property === "registerTool") {
|
|
3608
|
-
const registerTool = (name, config, callback) => {
|
|
3609
|
-
const registeredTool = target.registerTool(name, config, callback);
|
|
3610
|
-
count += 1;
|
|
3611
|
-
return registeredTool;
|
|
3612
|
-
};
|
|
3613
|
-
return registerTool;
|
|
3614
|
-
}
|
|
3615
|
-
return Reflect.get(target, property, receiver);
|
|
3616
|
-
} }),
|
|
3617
|
-
getCount: () => count
|
|
3618
|
-
};
|
|
3619
|
-
}
|
|
3620
3705
|
function getHttpStatus(error) {
|
|
3706
|
+
if (isHevyHttpError(error)) return error.status;
|
|
3621
3707
|
if (!error || typeof error !== "object" || !("response" in error)) return;
|
|
3622
3708
|
const response = error.response;
|
|
3623
3709
|
if (!response || typeof response !== "object" || !("status" in response)) return;
|
|
@@ -3655,45 +3741,14 @@ function buildServer(apiKey) {
|
|
|
3655
3741
|
try {
|
|
3656
3742
|
Sentry.setUser({ id: userId });
|
|
3657
3743
|
setCurrentUserId(userId);
|
|
3658
|
-
const
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
});
|
|
3665
|
-
const server = Sentry.wrapMcpServerWithSentry(baseServer);
|
|
3666
|
-
const clientLogger = createMcpClientLogger(server);
|
|
3667
|
-
const hevyClient = tracer.startActiveSpan("mcp.hevy-client.initialize", (childSpan) => {
|
|
3668
|
-
try {
|
|
3669
|
-
return createClient(apiKey, HEVY_API_BASEURL, { logger: clientLogger });
|
|
3670
|
-
} finally {
|
|
3671
|
-
childSpan.end();
|
|
3672
|
-
}
|
|
3744
|
+
const server = createSharedMcpServer({
|
|
3745
|
+
apiKey,
|
|
3746
|
+
clientOptions: createNodeHevyClientOptions(),
|
|
3747
|
+
wrapHandler: withObservability,
|
|
3748
|
+
wrapServer: (baseServer) => Sentry.wrapMcpServerWithSentry(baseServer),
|
|
3749
|
+
onToolsRegistered: (count) => span.setAttribute("mcp.tools.count", count)
|
|
3673
3750
|
});
|
|
3674
3751
|
console.error("Hevy client initialized with API key");
|
|
3675
|
-
tracer.startActiveSpan("mcp.tools.register", (toolsSpan) => {
|
|
3676
|
-
try {
|
|
3677
|
-
const counting = createToolCountingServer(server);
|
|
3678
|
-
registerWorkoutTools(counting.server, hevyClient);
|
|
3679
|
-
registerRoutineTools(counting.server, hevyClient);
|
|
3680
|
-
registerTemplateTools(counting.server, hevyClient, { logger: clientLogger });
|
|
3681
|
-
registerFolderTools(counting.server, hevyClient);
|
|
3682
|
-
registerBodyMeasurementTools(counting.server, hevyClient);
|
|
3683
|
-
registerUserTools(counting.server, hevyClient);
|
|
3684
|
-
toolsSpan.setAttribute("mcp.tools.count", counting.getCount());
|
|
3685
|
-
} finally {
|
|
3686
|
-
toolsSpan.end();
|
|
3687
|
-
}
|
|
3688
|
-
});
|
|
3689
|
-
registerWorkoutPrompts(server);
|
|
3690
|
-
tracer.startActiveSpan("mcp.resources.register", { attributes: { "mcp.resources.count": 4 } }, (resourcesSpan) => {
|
|
3691
|
-
try {
|
|
3692
|
-
registerHevyResources(server, hevyClient);
|
|
3693
|
-
} finally {
|
|
3694
|
-
resourcesSpan.end();
|
|
3695
|
-
}
|
|
3696
|
-
});
|
|
3697
3752
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
3698
3753
|
return server;
|
|
3699
3754
|
} catch (e) {
|
|
@@ -3710,8 +3765,7 @@ async function createServer({ config }) {
|
|
|
3710
3765
|
return buildServer(apiKey);
|
|
3711
3766
|
}
|
|
3712
3767
|
async function runServer() {
|
|
3713
|
-
const
|
|
3714
|
-
const cliAction = getCliAction(args);
|
|
3768
|
+
const cliAction = getCliAction(process.argv.slice(2));
|
|
3715
3769
|
if (cliAction === "version") {
|
|
3716
3770
|
console.error(`${name} v${version}`);
|
|
3717
3771
|
return;
|
|
@@ -3723,7 +3777,7 @@ async function runServer() {
|
|
|
3723
3777
|
serverStartups.add(1, { version });
|
|
3724
3778
|
await tracer.startActiveSpan("mcp.server.run", { attributes: { "mcp.transport": "stdio" } }, async (span) => {
|
|
3725
3779
|
try {
|
|
3726
|
-
const apiKey = parseConfig(
|
|
3780
|
+
const apiKey = parseConfig(process.env).apiKey;
|
|
3727
3781
|
assertApiKey(apiKey);
|
|
3728
3782
|
const server = await createServer({ config: { apiKey } });
|
|
3729
3783
|
console.error("Starting MCP server in stdio mode");
|
|
@@ -3754,6 +3808,6 @@ async function runServer() {
|
|
|
3754
3808
|
});
|
|
3755
3809
|
}
|
|
3756
3810
|
//#endregion
|
|
3757
|
-
export { createServer as n, runServer as r, configSchema as t };
|
|
3811
|
+
export { createSafeErrorDiagnostic as i, createServer as n, runServer as r, configSchema as t };
|
|
3758
3812
|
|
|
3759
|
-
//# sourceMappingURL=src-
|
|
3813
|
+
//# sourceMappingURL=src-CkkrTrrg.mjs.map
|