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.
@@ -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@2.0.0" };
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] = "17d5086b-9850-447d-9372-27bb2ce5dcc5", e._sentryDebugIdIdentifier = "sentry-dbid-17d5086b-9850-447d-9372-27bb2ce5dcc5");
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 axios, { isAxiosError } from "axios";
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 = "2.0.0";
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@2.0.0`;
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 exerciseTemplateCatalogCache = new AsyncTtlCache({
266
- ttlMs: 300 * 1e3,
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
- function getExerciseTemplateCatalog(hevyClient, options = {}) {
290
- const reason = options.refresh ? "explicit-refresh" : exerciseTemplateCatalogCache.size === 0 ? "initial-load" : "ttl-expired";
291
- return exerciseTemplateCatalogCache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, async () => {
292
- const catalog = await fetchExerciseTemplateCatalog(hevyClient);
293
- options.onRefreshed?.(catalog, reason);
294
- return catalog;
295
- }, options);
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:", error);
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
- return createJsonResourceResult(uri, (await getExerciseTemplateCatalog(requireClient(hevyClient))).map(formatExerciseTemplate));
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 (isAxiosError(error) && error.response?.status === 429) return "RATE_LIMIT";
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$1(value) {
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$1(value[0]);
734
+ if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
560
735
  }
561
- function getHeaderValue$1(headers, key) {
736
+ function getHeaderValue(headers, key) {
562
737
  if (!headers || typeof headers !== "object") return;
563
- if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue$1(headers.get(key));
738
+ if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
564
739
  const headerRecord = headers;
565
- return normalizeHeaderValue$1(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
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
- if (!isAxiosError(error)) return "Rate limited by Hevy. Please wait and retry.";
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 (isAxiosError(error) && error.response?.status === 429) return getRateLimitMessage(error);
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
- const originalErrorMessage = extractErrorMessage(error);
604
- let errorMessage = originalErrorMessage;
605
- const axiosErrorContext = extractAxiosErrorContext(error);
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 (!mappedHevyErrorMessage && axiosErrorContext?.data) errorMessage = stringifyErrorData(axiosErrorContext.data);
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 = error instanceof Error && "code" in error ? error.code : void 0;
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
- const errorCodeSuffix = errorCode ? `, Code: ${errorCode}` : "";
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
- const result = await fn(args);
843
- isError = Boolean(result.isError);
844
- span.setStatus({ code: isError ? SpanStatusCode.ERROR : SpanStatusCode.OK });
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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"), withObservability(async (args) => {
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"), withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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"), withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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"), withObservability(async (args) => {
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"), withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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"), withObservability(async (args) => {
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
- }, withObservability(async (args) => {
1774
+ }, wrapHandler(async (args) => {
1814
1775
  const client = requireClient(hevyClient);
1815
1776
  const { query, primaryMuscleGroup, refresh } = args;
1816
- const catalog = await getExerciseTemplateCatalog(client, {
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 = catalog.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
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
- }, withObservability(async (_args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async (args) => {
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
- }, withObservability(async () => {
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
- }, withObservability(async (args) => {
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"), withObservability(async (args) => {
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"), withObservability(async (args) => {
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/utils/config.ts
2114
- const DEPRECATED_CLI_ARGUMENT_WARNING = [
2115
- "DEPRECATION WARNING: Passing the Hevy API key via CLI arguments",
2116
- "(--hevy-api-key=..., --hevyApiKey=..., hevy-api-key=...) is",
2117
- "deprecated and insecure. Use the HEVY_API_KEY environment",
2118
- "variable instead."
2119
- ].join(" ");
2120
- /**
2121
- * Parse CLI arguments and environment to derive configuration.
2122
- * Priority order for API key: deprecated CLI flag forms > environment variable.
2123
- * Supported deprecated CLI arg forms:
2124
- * --hevy-api-key=KEY
2125
- * --hevyApiKey=KEY
2126
- * hevy-api-key=KEY (bare, e.g. when passed after npm start -- )
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 axiosInstance = axios.create(getConfig());
2256
- const fetch$1 = async (config) => {
2257
- return axiosInstance.request(mergeConfig(getConfig(), config)).catch((e) => {
2258
- throw e;
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 HEVY_RETRY_EXHAUSTED_ERROR_CODE = "HEVY_RETRY_EXHAUSTED";
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 getApiTimeoutMs() {
2868
- const rawValue = process.env.HEVY_MCP_API_TIMEOUT;
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 getExponentialBackoffMs(retryAttempt) {
2930
- const delay = 300 * 2 ** Math.max(0, retryAttempt - 1);
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 getRetryDelayMs(error, retryAttempt) {
2934
- const exponentialDelay = getExponentialBackoffMs(retryAttempt);
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 shouldRetryRequest(config, error) {
2940
- if (!isGetRequest(config)) return false;
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 url = config.url ?? "";
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 (error) {
2959
- console.error("Failed to emit structured Hevy API log", error);
2738
+ } catch {
2739
+ console.error("Failed to emit structured Hevy API log");
2960
2740
  }
2961
2741
  }
2962
- function markRetryExhausted(error, retryCount) {
2963
- const retryError = error;
2964
- retryError.hevyRetryExhausted = true;
2965
- retryError.hevyRetryCount = retryCount;
2966
- retryError.hevyRetryStatus = error.response?.status;
2967
- if (retryError.code) retryError.hevyRetryOriginalCode = retryError.code;
2968
- retryError.code = HEVY_RETRY_EXHAUSTED_ERROR_CODE;
2969
- }
2970
- async function requestWithRetries(axiosInstance, config, maxGetRetries, logger) {
2971
- let retryCount = 0;
2972
- while (true) try {
2973
- return await axiosInstance.request(config);
2974
- } catch (error) {
2975
- if (!isAxiosError(error)) throw error;
2976
- const normalizedConfig = config;
2977
- const { method, endpoint } = getRequestContext(normalizedConfig);
2978
- const status = error.response?.status ?? null;
2979
- if (!shouldRetryRequest(normalizedConfig, error)) {
2980
- emitClientLog(logger, {
2981
- level: "error",
2982
- logger: "hevy-api",
2983
- data: {
2984
- message: "Hevy API request failed without retry",
2985
- status,
2986
- method,
2987
- endpoint
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
- throw error;
2991
- }
2992
- if (retryCount >= maxGetRetries) {
2993
- emitClientLog(logger, {
2994
- level: status === 429 ? "warning" : "error",
2995
- logger: "hevy-api",
2996
- data: {
2997
- message: "Hevy API request failed after retries",
2998
- status,
2999
- attempt: retryCount + 1,
3000
- maxAttempts: maxGetRetries + 1,
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
- markRetryExhausted(error, retryCount);
3006
- throw error;
3007
- }
3008
- retryCount += 1;
3009
- const retryDelayMs = getRetryDelayMs(error, retryCount);
3010
- const retryAfterMs = getRetryAfterDelayMs(error);
3011
- emitClientLog(logger, {
3012
- level: status === 429 ? "warning" : "debug",
3013
- logger: "hevy-api",
3014
- data: {
3015
- message: status === 429 ? "Hevy API rate limit; retrying request" : "Retrying Hevy API request",
3016
- status,
3017
- attempt: retryCount + 1,
3018
- maxAttempts: maxGetRetries + 1,
3019
- delayMs: retryDelayMs,
3020
- ...status === 429 ? { retryAfterMs: retryAfterMs ?? null } : {},
3021
- method,
3022
- endpoint
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
- resilientClient.getConfig = () => ({ ...clientConfig });
3034
- resilientClient.setConfig = (config) => {
2898
+ client.getConfig = () => ({ ...clientConfig });
2899
+ client.setConfig = (config) => {
3035
2900
  clientConfig = {
3036
2901
  ...clientConfig,
3037
- ...config
2902
+ ...config,
2903
+ baseURL: baseUrl
3038
2904
  };
3039
- if (config.baseURL !== void 0) axiosInstance.defaults.baseURL = config.baseURL;
3040
- return resilientClient.getConfig();
2905
+ return client.getConfig();
3041
2906
  };
3042
- return resilientClient;
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 = createResilientClient(axiosInstance, maxGetRetries, logger);
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, { client })
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/utils/stdio-observability.ts
3213
- const UTF8_BOM = "";
3214
- /** Maximum escaped characters included in a malformed stdin shape preview. */
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
- wrapOnData(onChunk) {
3232
- const originalOnData = mutableTransport._ondata;
3233
- if (typeof originalOnData !== "function") return;
3234
- mutableTransport._ondata = (chunk) => {
3235
- onChunk(chunk);
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
- installReadMessageHook(onReadLine) {
3240
- const readBuffer = mutableTransport._readBuffer;
3241
- if (!readBuffer || typeof readBuffer.readMessage !== "function") return false;
3242
- readBuffer.readMessage = () => {
3243
- const buffer = readBuffer._buffer;
3244
- if (!buffer) return null;
3245
- const index = buffer.indexOf("\n");
3246
- if (index === -1) return null;
3247
- const lineBuffer = buffer.subarray(0, index);
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.recordException(error);
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
- errorName: error instanceof Error ? error.name : "UnknownError"
3504
+ errorCategory: diagnostic.category
3367
3505
  });
3368
- Sentry.captureException(error);
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 baseServer = new McpServer({
3659
- name,
3660
- version
3661
- }, {
3662
- capabilities: { logging: {} },
3663
- instructions: SERVER_INSTRUCTIONS
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 args = process.argv.slice(2);
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(args, process.env).apiKey;
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-c3mB-R0m.mjs.map
3813
+ //# sourceMappingURL=src-CkkrTrrg.mjs.map