hevy-mcp-server 1.0.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/dist/tools.js ADDED
@@ -0,0 +1,610 @@
1
+ /**
2
+ * MCP Tools for Hevy API interactions
3
+ * Provides tools for managing workouts, routines, and exercise data
4
+ */
5
+ // Tool definitions for MCP
6
+ export const toolDefinitions = [
7
+ {
8
+ name: 'get_workouts',
9
+ description: 'Get a paginated list of workouts from Hevy',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ page: {
14
+ type: 'number',
15
+ description: 'Page number (default: 1)',
16
+ minimum: 1,
17
+ },
18
+ pageSize: {
19
+ type: 'number',
20
+ description: 'Number of workouts per page (default: 10, max: 10)',
21
+ minimum: 1,
22
+ maximum: 10,
23
+ },
24
+ },
25
+ },
26
+ },
27
+ {
28
+ name: 'get_workout',
29
+ description: 'Get details of a specific workout by ID',
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ workoutId: {
34
+ type: 'string',
35
+ description: 'The unique workout ID',
36
+ },
37
+ },
38
+ required: ['workoutId'],
39
+ },
40
+ },
41
+ {
42
+ name: 'get_all_workouts',
43
+ description: 'Get all workouts (fetches all pages automatically)',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {},
47
+ },
48
+ },
49
+ {
50
+ name: 'create_workout',
51
+ description: 'Create a new workout',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ title: {
56
+ type: 'string',
57
+ description: 'Workout title',
58
+ },
59
+ description: {
60
+ type: 'string',
61
+ description: 'Workout description',
62
+ },
63
+ start_time: {
64
+ type: 'string',
65
+ description: 'Start time in ISO 8601 format (e.g., 2024-08-14T12:00:00Z)',
66
+ },
67
+ end_time: {
68
+ type: 'string',
69
+ description: 'End time in ISO 8601 format (optional)',
70
+ },
71
+ is_private: {
72
+ type: 'boolean',
73
+ description: 'Whether the workout is private',
74
+ },
75
+ exercises: {
76
+ type: 'array',
77
+ description: 'Array of exercises in the workout',
78
+ items: {
79
+ type: 'object',
80
+ properties: {
81
+ exercise_template_id: {
82
+ type: 'string',
83
+ description: 'Exercise template ID',
84
+ },
85
+ notes: {
86
+ type: 'string',
87
+ description: 'Notes for the exercise',
88
+ },
89
+ sets: {
90
+ type: 'array',
91
+ description: 'Array of sets for the exercise',
92
+ items: {
93
+ type: 'object',
94
+ properties: {
95
+ type: {
96
+ type: 'string',
97
+ enum: ['warmup', 'normal', 'failure', 'dropset'],
98
+ description: 'Set type',
99
+ },
100
+ weight_kg: {
101
+ type: 'number',
102
+ description: 'Weight in kilograms',
103
+ },
104
+ reps: {
105
+ type: 'number',
106
+ description: 'Number of repetitions',
107
+ },
108
+ rpe: {
109
+ type: 'number',
110
+ description: 'RPE (Rate of Perceived Exertion)',
111
+ },
112
+ },
113
+ required: ['type'],
114
+ },
115
+ },
116
+ },
117
+ required: ['exercise_template_id', 'sets'],
118
+ },
119
+ },
120
+ },
121
+ required: ['title', 'start_time', 'exercises'],
122
+ },
123
+ },
124
+ {
125
+ name: 'get_routines',
126
+ description: 'Get a paginated list of routines from Hevy',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ page: {
131
+ type: 'number',
132
+ description: 'Page number (default: 1)',
133
+ minimum: 1,
134
+ },
135
+ pageSize: {
136
+ type: 'number',
137
+ description: 'Number of routines per page (default: 10, max: 10)',
138
+ minimum: 1,
139
+ maximum: 10,
140
+ },
141
+ },
142
+ },
143
+ },
144
+ {
145
+ name: 'get_routine',
146
+ description: 'Get details of a specific routine by ID',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ routineId: {
151
+ type: 'string',
152
+ description: 'The unique routine ID',
153
+ },
154
+ },
155
+ required: ['routineId'],
156
+ },
157
+ },
158
+ {
159
+ name: 'get_all_routines',
160
+ description: 'Get all routines (fetches all pages automatically)',
161
+ inputSchema: {
162
+ type: 'object',
163
+ properties: {},
164
+ },
165
+ },
166
+ {
167
+ name: 'create_routine',
168
+ description: 'Create a new routine',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ title: {
173
+ type: 'string',
174
+ description: 'Routine title',
175
+ },
176
+ folder_id: {
177
+ type: 'number',
178
+ description: 'Folder ID to place the routine in (null for default folder)',
179
+ },
180
+ notes: {
181
+ type: 'string',
182
+ description: 'Notes for the routine',
183
+ },
184
+ exercises: {
185
+ type: 'array',
186
+ description: 'Array of exercises in the routine',
187
+ items: {
188
+ type: 'object',
189
+ properties: {
190
+ exercise_template_id: {
191
+ type: 'string',
192
+ description: 'Exercise template ID',
193
+ },
194
+ rest_seconds: {
195
+ type: 'number',
196
+ description: 'Rest time between sets in seconds',
197
+ },
198
+ notes: {
199
+ type: 'string',
200
+ description: 'Notes for the exercise',
201
+ },
202
+ sets: {
203
+ type: 'array',
204
+ description: 'Array of sets for the exercise',
205
+ items: {
206
+ type: 'object',
207
+ properties: {
208
+ type: {
209
+ type: 'string',
210
+ enum: ['warmup', 'normal', 'failure', 'dropset'],
211
+ description: 'Set type',
212
+ },
213
+ weight_kg: {
214
+ type: 'number',
215
+ description: 'Target weight in kilograms',
216
+ },
217
+ reps: {
218
+ type: 'number',
219
+ description: 'Target number of repetitions',
220
+ },
221
+ rep_range: {
222
+ type: 'object',
223
+ properties: {
224
+ start: {
225
+ type: 'number',
226
+ description: 'Minimum reps in range',
227
+ },
228
+ end: {
229
+ type: 'number',
230
+ description: 'Maximum reps in range',
231
+ },
232
+ },
233
+ description: 'Rep range for the set',
234
+ },
235
+ },
236
+ required: ['type'],
237
+ },
238
+ },
239
+ },
240
+ required: ['exercise_template_id', 'sets'],
241
+ },
242
+ },
243
+ },
244
+ required: ['title', 'exercises'],
245
+ },
246
+ },
247
+ {
248
+ name: 'get_routine_folders',
249
+ description: 'Get all routine folders',
250
+ inputSchema: {
251
+ type: 'object',
252
+ properties: {},
253
+ },
254
+ },
255
+ {
256
+ name: 'create_routine_folder',
257
+ description: 'Create a new routine folder',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ title: {
262
+ type: 'string',
263
+ description: 'Folder title',
264
+ },
265
+ },
266
+ required: ['title'],
267
+ },
268
+ },
269
+ {
270
+ name: 'get_exercise_templates',
271
+ description: 'Get all exercise templates available in Hevy',
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ search: {
276
+ type: 'string',
277
+ description: 'Optional search term to filter exercises',
278
+ },
279
+ },
280
+ },
281
+ },
282
+ {
283
+ name: 'search_exercise_templates',
284
+ description: 'Search for exercise templates by name or muscle group',
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ query: {
289
+ type: 'string',
290
+ description: 'Search query (exercise name or muscle group)',
291
+ },
292
+ muscle_group: {
293
+ type: 'string',
294
+ description: 'Filter by specific muscle group',
295
+ },
296
+ equipment: {
297
+ type: 'string',
298
+ description: 'Filter by equipment type',
299
+ },
300
+ },
301
+ required: ['query'],
302
+ },
303
+ },
304
+ {
305
+ name: 'get_exercise_history',
306
+ description: 'Get exercise history for a specific exercise template',
307
+ inputSchema: {
308
+ type: 'object',
309
+ properties: {
310
+ exercise_template_id: {
311
+ type: 'string',
312
+ description: 'Exercise template ID',
313
+ },
314
+ start_date: {
315
+ type: 'string',
316
+ description: 'Start date filter (ISO 8601 format)',
317
+ },
318
+ end_date: {
319
+ type: 'string',
320
+ description: 'End date filter (ISO 8601 format)',
321
+ },
322
+ },
323
+ required: ['exercise_template_id'],
324
+ },
325
+ },
326
+ {
327
+ name: 'get_workout_count',
328
+ description: 'Get the total number of workouts in the account',
329
+ inputSchema: {
330
+ type: 'object',
331
+ properties: {},
332
+ },
333
+ },
334
+ {
335
+ name: 'analyze_workout_trends',
336
+ description: 'Analyze workout trends and progress over time',
337
+ inputSchema: {
338
+ type: 'object',
339
+ properties: {
340
+ days: {
341
+ type: 'number',
342
+ description: 'Number of days to analyze (default: 30)',
343
+ minimum: 1,
344
+ },
345
+ exercise_template_id: {
346
+ type: 'string',
347
+ description: 'Focus analysis on specific exercise (optional)',
348
+ },
349
+ },
350
+ },
351
+ },
352
+ ];
353
+ // Tool implementations
354
+ export const tools = {
355
+ get_workouts: async (client, args) => {
356
+ const { page = 1, pageSize = 10 } = args;
357
+ const result = await client.getWorkouts(page, pageSize);
358
+ return {
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: JSON.stringify(result, null, 2),
363
+ },
364
+ ],
365
+ };
366
+ },
367
+ get_workout: async (client, args) => {
368
+ const { workoutId } = args;
369
+ const result = await client.getWorkout(workoutId);
370
+ return {
371
+ content: [
372
+ {
373
+ type: 'text',
374
+ text: JSON.stringify(result, null, 2),
375
+ },
376
+ ],
377
+ };
378
+ },
379
+ get_all_workouts: async (client) => {
380
+ const workouts = await client.getAllWorkouts();
381
+ return {
382
+ content: [
383
+ {
384
+ type: 'text',
385
+ text: JSON.stringify({ workouts, total: workouts.length }, null, 2),
386
+ },
387
+ ],
388
+ };
389
+ },
390
+ create_workout: async (client, args) => {
391
+ const result = await client.createWorkout(args);
392
+ return {
393
+ content: [
394
+ {
395
+ type: 'text',
396
+ text: JSON.stringify(result, null, 2),
397
+ },
398
+ ],
399
+ };
400
+ },
401
+ get_routines: async (client, args) => {
402
+ const { page = 1, pageSize = 10 } = args;
403
+ const result = await client.getRoutines(page, pageSize);
404
+ return {
405
+ content: [
406
+ {
407
+ type: 'text',
408
+ text: JSON.stringify(result, null, 2),
409
+ },
410
+ ],
411
+ };
412
+ },
413
+ get_routine: async (client, args) => {
414
+ const { routineId } = args;
415
+ const result = await client.getRoutine(routineId);
416
+ return {
417
+ content: [
418
+ {
419
+ type: 'text',
420
+ text: JSON.stringify(result, null, 2),
421
+ },
422
+ ],
423
+ };
424
+ },
425
+ get_all_routines: async (client) => {
426
+ const routines = await client.getAllRoutines();
427
+ return {
428
+ content: [
429
+ {
430
+ type: 'text',
431
+ text: JSON.stringify({ routines, total: routines.length }, null, 2),
432
+ },
433
+ ],
434
+ };
435
+ },
436
+ create_routine: async (client, args) => {
437
+ const result = await client.createRoutine(args);
438
+ return {
439
+ content: [
440
+ {
441
+ type: 'text',
442
+ text: JSON.stringify(result, null, 2),
443
+ },
444
+ ],
445
+ };
446
+ },
447
+ get_routine_folders: async (client) => {
448
+ const folders = await client.getAllRoutineFolders();
449
+ return {
450
+ content: [
451
+ {
452
+ type: 'text',
453
+ text: JSON.stringify({ routine_folders: folders, total: folders.length }, null, 2),
454
+ },
455
+ ],
456
+ };
457
+ },
458
+ create_routine_folder: async (client, args) => {
459
+ const { title } = args;
460
+ const result = await client.createRoutineFolder(title);
461
+ return {
462
+ content: [
463
+ {
464
+ type: 'text',
465
+ text: JSON.stringify(result, null, 2),
466
+ },
467
+ ],
468
+ };
469
+ },
470
+ get_exercise_templates: async (client, args) => {
471
+ const templates = await client.getAllExerciseTemplates();
472
+ let filteredTemplates = templates;
473
+ if (args.search) {
474
+ const searchLower = args.search.toLowerCase();
475
+ filteredTemplates = templates.filter(template => template.title.toLowerCase().includes(searchLower) ||
476
+ template.primary_muscle_group.toLowerCase().includes(searchLower) ||
477
+ template.secondary_muscle_groups.some(muscle => muscle.toLowerCase().includes(searchLower)));
478
+ }
479
+ return {
480
+ content: [
481
+ {
482
+ type: 'text',
483
+ text: JSON.stringify({
484
+ exercise_templates: filteredTemplates,
485
+ total: filteredTemplates.length,
486
+ search: args.search || null,
487
+ }, null, 2),
488
+ },
489
+ ],
490
+ };
491
+ },
492
+ search_exercise_templates: async (client, args) => {
493
+ const { query, muscle_group, equipment } = args;
494
+ const templates = await client.getAllExerciseTemplates();
495
+ const filtered = templates.filter(template => {
496
+ const queryMatch = !query || template.title.toLowerCase().includes(query.toLowerCase());
497
+ const muscleMatch = !muscle_group ||
498
+ template.primary_muscle_group.toLowerCase().includes(muscle_group.toLowerCase()) ||
499
+ template.secondary_muscle_groups.some(muscle => muscle.toLowerCase().includes(muscle_group.toLowerCase()));
500
+ // Note: Equipment filtering would require additional API data that's not in the current schema
501
+ const equipmentMatch = !equipment || template.title.toLowerCase().includes(equipment.toLowerCase());
502
+ return queryMatch && muscleMatch && equipmentMatch;
503
+ });
504
+ return {
505
+ content: [
506
+ {
507
+ type: 'text',
508
+ text: JSON.stringify({
509
+ exercise_templates: filtered,
510
+ total: filtered.length,
511
+ filters: { query, muscle_group, equipment },
512
+ }, null, 2),
513
+ },
514
+ ],
515
+ };
516
+ },
517
+ get_exercise_history: async (client, args) => {
518
+ const { exercise_template_id, start_date, end_date } = args;
519
+ const result = await client.getExerciseHistory(exercise_template_id, start_date, end_date);
520
+ return {
521
+ content: [
522
+ {
523
+ type: 'text',
524
+ text: JSON.stringify(result, null, 2),
525
+ },
526
+ ],
527
+ };
528
+ },
529
+ get_workout_count: async (client) => {
530
+ const result = await client.getWorkoutCount();
531
+ return {
532
+ content: [
533
+ {
534
+ type: 'text',
535
+ text: JSON.stringify(result, null, 2),
536
+ },
537
+ ],
538
+ };
539
+ },
540
+ analyze_workout_trends: async (client, args) => {
541
+ const { days = 30, exercise_template_id } = args;
542
+ const endDate = new Date();
543
+ const startDate = new Date();
544
+ startDate.setDate(endDate.getDate() - days);
545
+ // Get all workouts and filter by date range
546
+ const allWorkouts = await client.getAllWorkouts();
547
+ const recentWorkouts = allWorkouts.filter(workout => {
548
+ const workoutDate = new Date(workout.start_time);
549
+ return workoutDate >= startDate && workoutDate <= endDate;
550
+ });
551
+ let analysis = {
552
+ period: `${days} days`,
553
+ start_date: startDate.toISOString(),
554
+ end_date: endDate.toISOString(),
555
+ total_workouts: recentWorkouts.length,
556
+ workout_frequency: recentWorkouts.length / days,
557
+ };
558
+ if (exercise_template_id) {
559
+ // Analyze specific exercise
560
+ const exerciseHistory = await client.getExerciseHistory(exercise_template_id, startDate.toISOString(), endDate.toISOString());
561
+ const history = exerciseHistory.exercise_history;
562
+ if (history.length > 0) {
563
+ const weights = history.filter(h => h.weight_kg).map(h => h.weight_kg);
564
+ const reps = history.filter(h => h.reps).map(h => h.reps);
565
+ analysis.exercise_analysis = {
566
+ exercise_template_id,
567
+ total_sets: history.length,
568
+ weight_trend: weights.length > 0 ? {
569
+ min: Math.min(...weights),
570
+ max: Math.max(...weights),
571
+ avg: weights.reduce((a, b) => a + b, 0) / weights.length,
572
+ } : null,
573
+ rep_trend: reps.length > 0 ? {
574
+ min: Math.min(...reps),
575
+ max: Math.max(...reps),
576
+ avg: reps.reduce((a, b) => a + b, 0) / reps.length,
577
+ } : null,
578
+ };
579
+ }
580
+ }
581
+ else {
582
+ // General analysis
583
+ const exerciseCount = {};
584
+ let totalSets = 0;
585
+ recentWorkouts.forEach(workout => {
586
+ workout.exercises.forEach(exercise => {
587
+ exerciseCount[exercise.exercise_template_id] = (exerciseCount[exercise.exercise_template_id] || 0) + 1;
588
+ totalSets += exercise.sets.length;
589
+ });
590
+ });
591
+ analysis.general_analysis = {
592
+ total_sets: totalSets,
593
+ avg_sets_per_workout: recentWorkouts.length > 0 ? totalSets / recentWorkouts.length : 0,
594
+ unique_exercises: Object.keys(exerciseCount).length,
595
+ most_frequent_exercises: Object.entries(exerciseCount)
596
+ .sort(([, a], [, b]) => b - a)
597
+ .slice(0, 5)
598
+ .map(([exerciseId, count]) => ({ exercise_template_id: exerciseId, frequency: count })),
599
+ };
600
+ }
601
+ return {
602
+ content: [
603
+ {
604
+ type: 'text',
605
+ text: JSON.stringify(analysis, null, 2),
606
+ },
607
+ ],
608
+ };
609
+ },
610
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "hevy-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "Model Context Protocol (MCP) server for Hevy workout tracking app integration",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "hevy-mcp-server": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "directories": {
16
+ "doc": "docs",
17
+ "test": "tests"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsx src/index.ts",
22
+ "start": "node dist/index.js",
23
+ "test": "jest",
24
+ "test:watch": "jest --watch",
25
+ "lint": "tsc --noEmit",
26
+ "clean": "rm -rf dist",
27
+ "prepublishOnly": "npm run clean && npm run build && npm test",
28
+ "postinstall": "echo 'hevy-mcp-server installed! Set HEVY_API_KEY environment variable and run: hevy-mcp-server'"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "modelcontextprotocol",
33
+ "hevy",
34
+ "workout",
35
+ "fitness",
36
+ "api",
37
+ "exercise",
38
+ "training",
39
+ "gym",
40
+ "tracking"
41
+ ],
42
+ "author": "Noah Van Hart",
43
+ "license": "MIT",
44
+ "homepage": "https://github.com/noah-vh/hevy-mcp-server#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/noah-vh/hevy-mcp-server/issues"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/noah-vh/hevy-mcp-server.git"
51
+ },
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ },
55
+ "dependencies": {
56
+ "@modelcontextprotocol/sdk": "^1.25.1",
57
+ "@types/node": "^25.0.3",
58
+ "nodemon": "^3.1.11",
59
+ "ts-node": "^10.9.2",
60
+ "typescript": "^5.9.3"
61
+ },
62
+ "devDependencies": {
63
+ "@types/jest": "^30.0.0",
64
+ "jest": "^30.2.0",
65
+ "ts-jest": "^29.4.6",
66
+ "tsx": "^4.21.0"
67
+ }
68
+ }