hevy-mcp 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/chrisdoc-hevy-mcp-badge.png)](https://mseep.ai/app/chrisdoc-hevy-mcp)
2
+
1
3
  # hevy-mcp: Model Context Protocol Server for Hevy Fitness API
2
4
 
3
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
@@ -63,9 +65,9 @@ Make sure to replace `your-api-key-here` with your actual Hevy API key.
63
65
 
64
66
  ## Configuration
65
67
 
66
- Create a `.env` file in the project root with the following content:
68
+ Create a `.env` file in the project root (you can copy from [.env.sample](.env.sample)) with the following content:
67
69
 
68
- ```
70
+ ```env
69
71
  HEVY_API_KEY=your_hevy_api_key_here
70
72
  ```
71
73
 
@@ -90,7 +92,7 @@ npm start
90
92
 
91
93
  ## Available MCP Tools
92
94
 
93
- The server implements the following MCP tools:
95
+ The server implements the following MCP tools for interacting with the Hevy API:
94
96
 
95
97
  ### Workout Tools
96
98
  - `get-workouts`: Fetch and format workout data
@@ -117,7 +119,7 @@ The server implements the following MCP tools:
117
119
 
118
120
  ## Project Structure
119
121
 
120
- ```
122
+ ```plaintext
121
123
  hevy-mcp/
122
124
  ├── .env # Environment variables (API keys)
123
125
  ├── src/
@@ -134,9 +136,11 @@ hevy-mcp/
134
136
  │ └── validators.ts # Input validation helpers
135
137
  ├── scripts/ # Build and utility scripts
136
138
  └── tests/ # Test suite
139
+ ├── integration/ # Integration tests with real API
140
+ │ └── hevy-mcp.integration.test.ts # MCP server integration tests
137
141
  ```
138
142
 
139
- ## Development
143
+ ## Development Guide
140
144
 
141
145
  ### Code Style
142
146
 
@@ -146,9 +150,62 @@ This project uses Biome for code formatting and linting:
146
150
  npm run check
147
151
  ```
148
152
 
153
+ ### Testing
154
+
155
+ #### Run All Tests
156
+
157
+ To run all tests (unit and integration), use:
158
+
159
+ ```bash
160
+ npm test
161
+ ```
162
+
163
+ > **Note:** If the `HEVY_API_KEY` environment variable is set, integration tests will also run. If not, only unit tests will run.
164
+
165
+ #### Run Only Unit Tests
166
+
167
+ To run only unit tests (excluding integration tests):
168
+
169
+ ```bash
170
+ npx vitest run --exclude tests/integration/**
171
+ ```
172
+
173
+ Or with coverage:
174
+
175
+ ```bash
176
+ npx vitest run --coverage --exclude tests/integration/**
177
+ ```
178
+
179
+ #### Run Only Integration Tests
180
+
181
+ To run only the integration tests (requires a valid `HEVY_API_KEY`):
182
+
183
+ ```bash
184
+ npx vitest run tests/integration
185
+ ```
186
+
187
+ **Note:** The integration tests will fail if the `HEVY_API_KEY` environment variable is not set. This is by design to ensure that the tests are always run with a valid API key.
188
+
189
+ ##### GitHub Actions Configuration
190
+
191
+ For GitHub Actions:
192
+
193
+ 1. Unit tests will always run on every push and pull request
194
+ 2. Integration tests will only run if the `HEVY_API_KEY` secret is set in the repository settings
195
+
196
+ To set up the `HEVY_API_KEY` secret:
197
+
198
+ 1. Go to your GitHub repository
199
+ 2. Click on "Settings" > "Secrets and variables" > "Actions"
200
+ 3. Click on "New repository secret"
201
+ 4. Set the name to `HEVY_API_KEY` and the value to your Hevy API key
202
+ 5. Click "Add secret"
203
+
204
+ If the secret is not set, the integration tests step will be skipped with a message indicating that the API key is missing.
205
+
149
206
  ### Generating API Client
150
207
 
151
- The API client is generated from the OpenAPI specification using Kiota:
208
+ The API client is generated from the OpenAPI specification using [Kiota](https://github.com/microsoft/kiota):
152
209
 
153
210
  ```bash
154
211
  npm run export-specs
@@ -157,11 +214,11 @@ npm run build:client
157
214
 
158
215
  ## License
159
216
 
160
- This project is licensed under the MIT License - see the LICENSE file for details.
217
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
161
218
 
162
219
  ## Contributing
163
220
 
164
- Contributions are welcome! Please feel free to submit a Pull Request.
221
+ Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
165
222
 
166
223
  ## Acknowledgements
167
224
 
package/dist/index.js CHANGED
@@ -72,13 +72,24 @@ function formatRoutineFolder(folder) {
72
72
  }
73
73
  function calculateDuration(startTime, endTime) {
74
74
  if (!startTime || !endTime) return "Unknown duration";
75
- const start = new Date(startTime);
76
- const end = new Date(endTime);
77
- const durationMs = end.getTime() - start.getTime();
78
- const hours = Math.floor(durationMs / (1e3 * 60 * 60));
79
- const minutes = Math.floor(durationMs % (1e3 * 60 * 60) / (1e3 * 60));
80
- const seconds = Math.floor(durationMs % (1e3 * 60) / 1e3);
81
- return `${hours}h ${minutes}m ${seconds}s`;
75
+ try {
76
+ const start = new Date(startTime);
77
+ const end = new Date(endTime);
78
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
79
+ return "Unknown duration";
80
+ }
81
+ const durationMs = end.getTime() - start.getTime();
82
+ if (durationMs < 0) {
83
+ return "Invalid duration (end time before start time)";
84
+ }
85
+ const hours = Math.floor(durationMs / (1e3 * 60 * 60));
86
+ const minutes = Math.floor(durationMs % (1e3 * 60 * 60) / (1e3 * 60));
87
+ const seconds = Math.floor(durationMs % (1e3 * 60) / 1e3);
88
+ return `${hours}h ${minutes}m ${seconds}s`;
89
+ } catch (error) {
90
+ console.error("Error calculating duration:", error);
91
+ return "Unknown duration";
92
+ }
82
93
  }
83
94
  function formatExerciseTemplate(template) {
84
95
  return {
@@ -569,6 +580,61 @@ function registerTemplateTools(server2, hevyClient2) {
569
580
 
570
581
  // src/tools/workouts.ts
571
582
  import { z as z4 } from "zod";
583
+
584
+ // src/utils/error-handler.ts
585
+ function createErrorResponse(error, context) {
586
+ const errorMessage = error instanceof Error ? error.message : String(error);
587
+ const errorCode = error instanceof Error && "code" in error ? error.code : void 0;
588
+ if (errorCode) {
589
+ console.debug(`Error code: ${errorCode}`);
590
+ }
591
+ const contextPrefix = context ? `[${context}] ` : "";
592
+ const formattedMessage = `${contextPrefix}Error: ${errorMessage}`;
593
+ console.error(formattedMessage, error);
594
+ return {
595
+ content: [
596
+ {
597
+ type: "text",
598
+ text: formattedMessage
599
+ }
600
+ ],
601
+ isError: true
602
+ };
603
+ }
604
+ function withErrorHandling(fn, context) {
605
+ return async (...args) => {
606
+ try {
607
+ return await fn(...args);
608
+ } catch (error) {
609
+ return createErrorResponse(error, context);
610
+ }
611
+ };
612
+ }
613
+
614
+ // src/utils/response-formatter.ts
615
+ function createJsonResponse(data, options = { pretty: true, indent: 2 }) {
616
+ const jsonString = options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data);
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ text: jsonString
622
+ }
623
+ ]
624
+ };
625
+ }
626
+ function createEmptyResponse(message = "No data found") {
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: message
632
+ }
633
+ ]
634
+ };
635
+ }
636
+
637
+ // src/tools/workouts.ts
572
638
  function registerWorkoutTools(server2, hevyClient2) {
573
639
  server2.tool(
574
640
  "get-workouts",
@@ -577,36 +643,21 @@ function registerWorkoutTools(server2, hevyClient2) {
577
643
  page: z4.coerce.number().gte(1).default(1),
578
644
  pageSize: z4.coerce.number().int().gte(1).lte(10).default(5)
579
645
  },
580
- async ({ page, pageSize }, extra) => {
581
- try {
582
- const data = await hevyClient2.v1.workouts.get({
583
- queryParameters: {
584
- page,
585
- pageSize
586
- }
587
- });
588
- const workouts = data?.workouts?.map((workout) => formatWorkout(workout)) || [];
589
- return {
590
- content: [
591
- {
592
- type: "text",
593
- text: JSON.stringify(workouts, null, 2)
594
- }
595
- ]
596
- };
597
- } catch (error) {
598
- console.error("Error fetching workouts:", error);
599
- return {
600
- content: [
601
- {
602
- type: "text",
603
- text: `Error fetching workouts: ${error instanceof Error ? error.message : String(error)}`
604
- }
605
- ],
606
- isError: true
607
- };
646
+ withErrorHandling(async ({ page, pageSize }) => {
647
+ const data = await hevyClient2.v1.workouts.get({
648
+ queryParameters: {
649
+ page,
650
+ pageSize
651
+ }
652
+ });
653
+ const workouts = data?.workouts?.map((workout) => formatWorkout(workout)) || [];
654
+ if (workouts.length === 0) {
655
+ return createEmptyResponse(
656
+ "No workouts found for the specified parameters"
657
+ );
608
658
  }
609
- }
659
+ return createJsonResponse(workouts);
660
+ }, "get-workouts")
610
661
  );
611
662
  server2.tool(
612
663
  "get-workout",
@@ -614,70 +665,24 @@ function registerWorkoutTools(server2, hevyClient2) {
614
665
  {
615
666
  workoutId: z4.string().min(1)
616
667
  },
617
- async ({ workoutId }, extra) => {
618
- try {
619
- const data = await hevyClient2.v1.workouts.byWorkoutId(workoutId).get();
620
- if (!data) {
621
- return {
622
- content: [
623
- {
624
- type: "text",
625
- text: `Workout with ID ${workoutId} not found`
626
- }
627
- ]
628
- };
629
- }
630
- const workout = formatWorkout(data);
631
- return {
632
- content: [
633
- {
634
- type: "text",
635
- text: JSON.stringify(workout, null, 2)
636
- }
637
- ]
638
- };
639
- } catch (error) {
640
- console.error(`Error fetching workout ${workoutId}:`, error);
641
- return {
642
- content: [
643
- {
644
- type: "text",
645
- text: `Error fetching workout: ${error instanceof Error ? error.message : String(error)}`
646
- }
647
- ],
648
- isError: true
649
- };
668
+ withErrorHandling(async ({ workoutId }) => {
669
+ const data = await hevyClient2.v1.workouts.byWorkoutId(workoutId).get();
670
+ if (!data) {
671
+ return createEmptyResponse(`Workout with ID ${workoutId} not found`);
650
672
  }
651
- }
673
+ const workout = formatWorkout(data);
674
+ return createJsonResponse(workout);
675
+ }, "get-workout")
652
676
  );
653
677
  server2.tool(
654
678
  "get-workout-count",
655
679
  "Get the total number of workouts on the account. Useful for pagination or statistics.",
656
680
  {},
657
- async (_, extra) => {
658
- try {
659
- const data = await hevyClient2.v1.workouts.count.get();
660
- return {
661
- content: [
662
- {
663
- type: "text",
664
- text: `Total workouts: ${data ? data.workoutCount || 0 : 0}`
665
- }
666
- ]
667
- };
668
- } catch (error) {
669
- console.error("Error fetching workout count:", error);
670
- return {
671
- content: [
672
- {
673
- type: "text",
674
- text: `Error fetching workout count: ${error instanceof Error ? error.message : String(error)}`
675
- }
676
- ],
677
- isError: true
678
- };
679
- }
680
- }
681
+ withErrorHandling(async () => {
682
+ const data = await hevyClient2.v1.workouts.count.get();
683
+ const count = data ? data.workoutCount || 0 : 0;
684
+ return createJsonResponse({ count });
685
+ }, "get-workout-count")
681
686
  );
682
687
  server2.tool(
683
688
  "get-workout-events",
@@ -687,36 +692,22 @@ function registerWorkoutTools(server2, hevyClient2) {
687
692
  pageSize: z4.coerce.number().int().gte(1).lte(10).default(5),
688
693
  since: z4.string().default("1970-01-01T00:00:00Z")
689
694
  },
690
- async ({ page, pageSize, since }, extra) => {
691
- try {
692
- const data = await hevyClient2.v1.workouts.events.get({
693
- queryParameters: {
694
- page,
695
- pageSize,
696
- since
697
- }
698
- });
699
- return {
700
- content: [
701
- {
702
- type: "text",
703
- text: JSON.stringify(data?.events || [], null, 2)
704
- }
705
- ]
706
- };
707
- } catch (error) {
708
- console.error("Error fetching workout events:", error);
709
- return {
710
- content: [
711
- {
712
- type: "text",
713
- text: `Error fetching workout events: ${error instanceof Error ? error.message : String(error)}`
714
- }
715
- ],
716
- isError: true
717
- };
695
+ withErrorHandling(async ({ page, pageSize, since }) => {
696
+ const data = await hevyClient2.v1.workouts.events.get({
697
+ queryParameters: {
698
+ page,
699
+ pageSize,
700
+ since
701
+ }
702
+ });
703
+ const events = data?.events || [];
704
+ if (events.length === 0) {
705
+ return createEmptyResponse(
706
+ `No workout events found for the specified parameters since ${since}`
707
+ );
718
708
  }
719
- }
709
+ return createJsonResponse(events);
710
+ }, "get-workout-events")
720
711
  );
721
712
  server2.tool(
722
713
  "create-workout",
@@ -746,8 +737,15 @@ function registerWorkoutTools(server2, hevyClient2) {
746
737
  })
747
738
  )
748
739
  },
749
- async ({ title, description, startTime, endTime, isPrivate, exercises }, extra) => {
750
- try {
740
+ withErrorHandling(
741
+ async ({
742
+ title,
743
+ description,
744
+ startTime,
745
+ endTime,
746
+ isPrivate,
747
+ exercises
748
+ }) => {
751
749
  const requestBody = {
752
750
  workout: {
753
751
  title,
@@ -773,38 +771,18 @@ function registerWorkoutTools(server2, hevyClient2) {
773
771
  };
774
772
  const data = await hevyClient2.v1.workouts.post(requestBody);
775
773
  if (!data) {
776
- return {
777
- content: [
778
- {
779
- type: "text",
780
- text: "Failed to create workout"
781
- }
782
- ]
783
- };
774
+ return createEmptyResponse(
775
+ "Failed to create workout: Server returned no data"
776
+ );
784
777
  }
785
778
  const workout = formatWorkout(data);
786
- return {
787
- content: [
788
- {
789
- type: "text",
790
- text: `Workout created successfully:
791
- ${JSON.stringify(workout, null, 2)}`
792
- }
793
- ]
794
- };
795
- } catch (error) {
796
- console.error("Error creating workout:", error);
797
- return {
798
- content: [
799
- {
800
- type: "text",
801
- text: `Error creating workout: ${error instanceof Error ? error.message : String(error)}`
802
- }
803
- ],
804
- isError: true
805
- };
806
- }
807
- }
779
+ return createJsonResponse(workout, {
780
+ pretty: true,
781
+ indent: 2
782
+ });
783
+ },
784
+ "create-workout"
785
+ )
808
786
  );
809
787
  server2.tool(
810
788
  "update-workout",
@@ -835,16 +813,16 @@ ${JSON.stringify(workout, null, 2)}`
835
813
  })
836
814
  )
837
815
  },
838
- async ({
839
- workoutId,
840
- title,
841
- description,
842
- startTime,
843
- endTime,
844
- isPrivate,
845
- exercises
846
- }, extra) => {
847
- try {
816
+ withErrorHandling(
817
+ async ({
818
+ workoutId,
819
+ title,
820
+ description,
821
+ startTime,
822
+ endTime,
823
+ isPrivate,
824
+ exercises
825
+ }) => {
848
826
  const requestBody = {
849
827
  workout: {
850
828
  title,
@@ -870,38 +848,18 @@ ${JSON.stringify(workout, null, 2)}`
870
848
  };
871
849
  const data = await hevyClient2.v1.workouts.byWorkoutId(workoutId).put(requestBody);
872
850
  if (!data) {
873
- return {
874
- content: [
875
- {
876
- type: "text",
877
- text: `Failed to update workout with ID ${workoutId}`
878
- }
879
- ]
880
- };
851
+ return createEmptyResponse(
852
+ `Failed to update workout with ID ${workoutId}`
853
+ );
881
854
  }
882
855
  const workout = formatWorkout(data);
883
- return {
884
- content: [
885
- {
886
- type: "text",
887
- text: `Workout updated successfully:
888
- ${JSON.stringify(workout, null, 2)}`
889
- }
890
- ]
891
- };
892
- } catch (error) {
893
- console.error(`Error updating workout ${workoutId}:`, error);
894
- return {
895
- content: [
896
- {
897
- type: "text",
898
- text: `Error updating workout: ${error instanceof Error ? error.message : String(error)}`
899
- }
900
- ],
901
- isError: true
902
- };
903
- }
904
- }
856
+ return createJsonResponse(workout, {
857
+ pretty: true,
858
+ indent: 2
859
+ });
860
+ },
861
+ "update-workout-operation"
862
+ )
905
863
  );
906
864
  }
907
865
 
@@ -2136,7 +2094,7 @@ function createClient(apiKey2, baseUrl) {
2136
2094
 
2137
2095
  // package.json
2138
2096
  var name = "hevy-mcp";
2139
- var version = "1.2.2";
2097
+ var version = "1.3.1";
2140
2098
 
2141
2099
  // src/index.ts
2142
2100
  var HEVY_API_BASEURL = "https://api.hevyapp.com";