@etweisberg/garmin-connect-mcp 0.1.16 → 0.1.18

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.
@@ -109,6 +109,12 @@ export class GarminClient {
109
109
  if (result.status === 204 || (result.status === 200 && !result.body)) {
110
110
  return { noData: true, status: result.status, path };
111
111
  }
112
+ if (result.status === 401) {
113
+ // Invalidate the singleton so the next call re-reads the session file
114
+ _sharedClient = null;
115
+ await this.close();
116
+ throw new Error(`Garmin API 401: ${path} — ${result.body}`);
117
+ }
112
118
  if (result.status !== 200) {
113
119
  throw new Error(`Garmin API ${result.status}: ${path} — ${result.body}`);
114
120
  }
@@ -200,6 +206,12 @@ export function getSharedClient() {
200
206
  }
201
207
  return _sharedClient;
202
208
  }
209
+ export async function resetSharedClient() {
210
+ if (_sharedClient) {
211
+ await _sharedClient.close();
212
+ _sharedClient = null;
213
+ }
214
+ }
203
215
  // Clean up on process exit
204
216
  process.on("exit", () => {
205
217
  _sharedClient?.close();
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { registerTools } from "./tools.js";
4
+ import { registerTools, registerResources } from "./tools.js";
5
5
  async function startMcpServer() {
6
6
  const server = new McpServer({
7
7
  name: "garmin-connect-mcp",
8
8
  version: "0.1.0",
9
9
  });
10
10
  registerTools(server);
11
+ registerResources(server);
11
12
  const transport = new StdioServerTransport();
12
13
  await server.connect(transport);
13
14
  console.error("garmin-connect-mcp server running on stdio");
package/dist/test.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Run: npm test
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
- import { registerTools } from "./tools.js";
10
+ import { registerTools, registerResources } from "./tools.js";
11
11
  import { existsSync, rmSync } from "node:fs";
12
12
  import { getSharedClient } from "./garmin-client.js";
13
13
  const TEST_FIT_DIR = "/tmp/garmin-mcp-test-fit";
@@ -16,12 +16,114 @@ async function callTool(server, name, args = {}) {
16
16
  const result = (await server._registeredTools[name].handler({ ...args }, { signal: new AbortController().signal }));
17
17
  return result;
18
18
  }
19
+ async function callResource(server, uri) {
20
+ const resource = server._registeredResources[uri];
21
+ if (!resource)
22
+ throw new Error(`Resource not registered: ${uri}`);
23
+ const result = (await resource.readCallback(new URL(uri), {
24
+ signal: new AbortController().signal,
25
+ }));
26
+ return result;
27
+ }
19
28
  function getToolText(result) {
20
29
  return result.content[0]?.text ?? "";
21
30
  }
22
31
  function getToolJson(result) {
23
32
  return JSON.parse(getToolText(result));
24
33
  }
34
+ // ── Resource tests (no session required) ──────────────────────────────
35
+ const resourceTests = [
36
+ {
37
+ name: "resource: workout://templates/simple-run",
38
+ run: async (server) => {
39
+ const uri = "workout://templates/simple-run";
40
+ const result = await callResource(server, uri);
41
+ if (!result.contents || result.contents.length === 0)
42
+ throw new Error("no contents");
43
+ const content = result.contents[0];
44
+ if (content.uri !== uri)
45
+ throw new Error(`wrong uri: ${content.uri}`);
46
+ if (content.mimeType !== "application/json")
47
+ throw new Error(`wrong mimeType: ${content.mimeType}`);
48
+ const data = JSON.parse(content.text);
49
+ if (!data.workoutName)
50
+ throw new Error("no workoutName");
51
+ if (!data.sportType?.sportTypeKey)
52
+ throw new Error("no sportTypeKey");
53
+ if (!Array.isArray(data.workoutSegments) ||
54
+ data.workoutSegments.length === 0)
55
+ throw new Error("no workoutSegments");
56
+ },
57
+ },
58
+ {
59
+ name: "resource: workout://templates/interval-running",
60
+ run: async (server) => {
61
+ const uri = "workout://templates/interval-running";
62
+ const result = await callResource(server, uri);
63
+ const content = result.contents[0];
64
+ if (content.mimeType !== "application/json")
65
+ throw new Error(`wrong mimeType: ${content.mimeType}`);
66
+ const data = JSON.parse(content.text);
67
+ if (data.workoutName !== "Interval Running")
68
+ throw new Error(`unexpected workoutName: ${data.workoutName}`);
69
+ // Interval running must have a RepeatGroupDTO
70
+ const steps = data.workoutSegments[0]?.workoutSteps ?? [];
71
+ const hasRepeatGroup = steps.some((s) => s.type === "RepeatGroupDTO");
72
+ if (!hasRepeatGroup)
73
+ throw new Error("missing RepeatGroupDTO");
74
+ },
75
+ },
76
+ {
77
+ name: "resource: workout://templates/tempo-run",
78
+ run: async (server) => {
79
+ const uri = "workout://templates/tempo-run";
80
+ const result = await callResource(server, uri);
81
+ const content = result.contents[0];
82
+ const data = JSON.parse(content.text);
83
+ if (data.workoutName !== "Tempo Run")
84
+ throw new Error(`unexpected workoutName: ${data.workoutName}`);
85
+ if (data.sportType.sportTypeKey !== "running")
86
+ throw new Error("expected running sport type");
87
+ const steps = data.workoutSegments[0]?.workoutSteps ?? [];
88
+ if (steps.length < 3)
89
+ throw new Error("expected at least warmup/interval/cooldown steps");
90
+ },
91
+ },
92
+ {
93
+ name: "resource: workout://templates/strength-circuit",
94
+ run: async (server) => {
95
+ const uri = "workout://templates/strength-circuit";
96
+ const result = await callResource(server, uri);
97
+ const content = result.contents[0];
98
+ const data = JSON.parse(content.text);
99
+ if (data.workoutName !== "Strength Circuit")
100
+ throw new Error(`unexpected workoutName: ${data.workoutName}`);
101
+ if (data.sportType.sportTypeKey !== "fitness_equipment")
102
+ throw new Error("expected fitness_equipment sport type");
103
+ // Must have a RepeatGroupDTO
104
+ const steps = data.workoutSegments[0]?.workoutSteps ?? [];
105
+ const hasRepeatGroup = steps.some((s) => s.type === "RepeatGroupDTO");
106
+ if (!hasRepeatGroup)
107
+ throw new Error("missing RepeatGroupDTO");
108
+ },
109
+ },
110
+ {
111
+ name: "resource: workout://reference/structure",
112
+ run: async (server) => {
113
+ const uri = "workout://reference/structure";
114
+ const result = await callResource(server, uri);
115
+ if (!result.contents || result.contents.length === 0)
116
+ throw new Error("no contents");
117
+ const content = result.contents[0];
118
+ if (content.uri !== uri)
119
+ throw new Error(`wrong uri: ${content.uri}`);
120
+ if (content.mimeType !== "text/markdown")
121
+ throw new Error(`wrong mimeType: ${content.mimeType}`);
122
+ if (!content.text.includes("Workout"))
123
+ throw new Error("reference text missing expected content");
124
+ },
125
+ },
126
+ ];
25
127
  const tests = [
26
128
  // ── Session ────────────────────────────────────────────────────────
27
129
  {
@@ -457,13 +559,35 @@ const tests = [
457
559
  let activityId = "";
458
560
  async function main() {
459
561
  console.log("garmin-connect-mcp integration tests (tool-level)\n");
460
- // Set up a real MCP server with all tools registered
562
+ // Set up a real MCP server with all tools and resources registered
461
563
  const server = new McpServer({
462
564
  name: "garmin-connect-mcp-test",
463
565
  version: "0.0.0",
464
566
  });
465
567
  registerTools(server);
466
- // Bootstrap: get a recent activityId
568
+ registerResources(server);
569
+ // ── Run resource tests (no session required) ────────────────────────
570
+ console.log("── Resources (no session required) ──\n");
571
+ let passed = 0;
572
+ let failed = 0;
573
+ for (const test of resourceTests) {
574
+ const start = Date.now();
575
+ try {
576
+ await test.run(server);
577
+ const ms = Date.now() - start;
578
+ console.log(` PASS ${test.name} (${ms}ms)`);
579
+ passed++;
580
+ }
581
+ catch (e) {
582
+ const ms = Date.now() - start;
583
+ const msg = e instanceof Error ? e.message : String(e);
584
+ const short = msg.length > 120 ? msg.slice(0, 120) + "..." : msg;
585
+ console.log(` FAIL ${test.name} (${ms}ms) — ${short}`);
586
+ failed++;
587
+ }
588
+ }
589
+ // ── Bootstrap: get a recent activityId ─────────────────────────────
590
+ console.log("\n── Integration tests (session required) ──\n");
467
591
  console.log("Bootstrapping...");
468
592
  const listResult = await callTool(server, "list-activities", {
469
593
  limit: 1,
@@ -476,8 +600,6 @@ async function main() {
476
600
  process.exit(1);
477
601
  }
478
602
  console.log(` activityId: ${activityId}\n`);
479
- let passed = 0;
480
- let failed = 0;
481
603
  for (const test of tests) {
482
604
  const start = Date.now();
483
605
  try {
package/dist/tools.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { writeFileSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { getSharedClient, sessionExists, getSessionFile, } from "./garmin-client.js";
4
+ import { inflateRawSync } from "node:zlib";
5
+ import { getSharedClient, resetSharedClient, sessionExists, getSessionFile, } from "./garmin-client.js";
5
6
  function jsonResult(data) {
6
7
  return {
7
8
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
@@ -88,6 +89,8 @@ To authenticate, you need the Playwright MCP server installed (\`@playwright/mcp
88
89
  }
89
90
  catch (e) {
90
91
  const msg = e instanceof Error ? e.message : String(e);
92
+ // Reset the singleton so the next attempt re-reads the session file
93
+ await resetSharedClient();
91
94
  return errorResult(`Session invalid or expired: ${msg}\nCall the garmin-login tool to re-authenticate.`);
92
95
  }
93
96
  });
@@ -440,15 +443,36 @@ To authenticate, you need the Playwright MCP server installed (\`@playwright/mcp
440
443
  writeFileSync(outPath, fitBytes);
441
444
  return textResult(`Downloaded workout FIT: ${outPath} (${fitBytes.length} bytes)`);
442
445
  });
443
- server.tool("create-workout", `Create a new workout on Garmin Connect. Pass a workout JSON object with workoutName, sportType, and workoutSegments containing steps.
446
+ server.tool("create-workout", `Upload a workout from JSON data.
447
+
448
+ Creates a new workout in Garmin Connect from structured workout data.
449
+
450
+ IMPORTANT: Step types must use Garmin's DTO format:
451
+ - Use "ExecutableStepDTO" for regular steps (warmup, interval, cooldown, recovery)
452
+ - Use "RepeatGroupDTO" for repeat/interval groups with numberOfIterations
453
+
454
+ IMPORTANT: For heart rate zone targets, use "zoneNumber" (1-5), NOT targetValueOne/targetValueTwo.
455
+ targetValueOne/targetValueTwo are only for absolute value ranges (e.g. pace in m/s, power in watts).
444
456
 
445
457
  Sport type IDs: 1=running, 2=cycling, 3=swimming, 4=walking, 5=multi, 6=fitness, 7=hiking.
446
- Step types: warmup (1), cooldown (2), interval (3), recovery (4), rest (5).
447
- End conditions: distance (1, meters), time (2, seconds), open (7).
458
+ Step type IDs: warmup (1), cooldown (2), interval (3), recovery (4), rest (5).
459
+ End condition IDs: distance (1, value in meters), time (2, value in seconds), open (7, no value needed).
460
+ Target type IDs: no.target (1), speed (2, m/s range via targetValueOne/targetValueTwo), heart.rate.zone (4, use zoneNumber 1-5), power.zone (11, use zoneNumber).
448
461
 
449
- Example minimal running workout:
462
+ **Available Templates:**
463
+ Instead of building workout JSON from scratch, use these MCP resources as starting points:
464
+ - workout://templates/simple-run - Basic warmup/run/cooldown structure
465
+ - workout://templates/interval-running - Interval training with repeat groups
466
+ - workout://templates/tempo-run - Tempo run with heart rate zone targets
467
+ - workout://templates/strength-circuit - Strength training circuit structure
468
+ - workout://reference/structure - Complete JSON structure reference with all fields
469
+
470
+ Access these resources using your MCP client's resource reading capability, modify the template
471
+ as needed, and pass the resulting JSON as the workout parameter.
472
+
473
+ Example workout structure with HR zone target:
450
474
  {
451
- "workoutName": "Easy 30min Run",
475
+ "workoutName": "My Workout",
452
476
  "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
453
477
  "workoutSegments": [{
454
478
  "segmentOrder": 1,
@@ -456,26 +480,66 @@ Example minimal running workout:
456
480
  "workoutSteps": [{
457
481
  "type": "ExecutableStepDTO",
458
482
  "stepOrder": 1,
459
- "stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"},
460
- "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
461
- "endConditionValue": 300,
462
- "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
463
- }, {
464
- "type": "ExecutableStepDTO",
465
- "stepOrder": 2,
466
483
  "stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
467
484
  "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
468
- "endConditionValue": 1200,
469
- "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
470
- }, {
471
- "type": "ExecutableStepDTO",
472
- "stepOrder": 3,
473
- "stepType": {"stepTypeId": 2, "stepTypeKey": "cooldown"},
474
- "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
475
- "endConditionValue": 300,
476
- "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
485
+ "endConditionValue": 1200.0,
486
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
487
+ "zoneNumber": 3
477
488
  }]
478
489
  }]
490
+ }
491
+
492
+ Example with RepeatGroupDTO for intervals:
493
+ {
494
+ "workoutName": "Interval Run",
495
+ "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
496
+ "workoutSegments": [{
497
+ "segmentOrder": 1,
498
+ "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
499
+ "workoutSteps": [
500
+ {
501
+ "type": "ExecutableStepDTO",
502
+ "stepOrder": 1,
503
+ "stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"},
504
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
505
+ "endConditionValue": 600.0,
506
+ "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
507
+ },
508
+ {
509
+ "type": "RepeatGroupDTO",
510
+ "stepOrder": 2,
511
+ "numberOfIterations": 6,
512
+ "workoutSteps": [
513
+ {
514
+ "type": "ExecutableStepDTO",
515
+ "stepOrder": 1,
516
+ "stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
517
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
518
+ "endConditionValue": 60.0,
519
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
520
+ "zoneNumber": 5
521
+ },
522
+ {
523
+ "type": "ExecutableStepDTO",
524
+ "stepOrder": 2,
525
+ "stepType": {"stepTypeId": 4, "stepTypeKey": "recovery"},
526
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
527
+ "endConditionValue": 90.0,
528
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
529
+ "zoneNumber": 2
530
+ }
531
+ ]
532
+ },
533
+ {
534
+ "type": "ExecutableStepDTO",
535
+ "stepOrder": 3,
536
+ "stepType": {"stepTypeId": 2, "stepTypeKey": "cooldown"},
537
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
538
+ "endConditionValue": 600.0,
539
+ "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
540
+ }
541
+ ]
542
+ }]
479
543
  }`, {
480
544
  workout: z
481
545
  .string()
@@ -564,7 +628,307 @@ Present results as a markdown table: | Tool | Status | Notes |
564
628
  Count total passed vs failed at the end.`);
565
629
  });
566
630
  }
567
- import { inflateRawSync } from "node:zlib";
631
+ // ── Workout Templates (MCP Resources) ──────────────────────────────────────
632
+ // Templates adapted from Taxuspt/garmin_mcp (MIT License, Copyright (c) 2025 Alexandre Domingues)
633
+ // https://github.com/Taxuspt/garmin_mcp/blob/main/src/garmin_mcp/workout_templates.py
634
+ const WORKOUT_TEMPLATES = {
635
+ "simple-run": {
636
+ workoutName: "Simple Run",
637
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
638
+ workoutSegments: [
639
+ {
640
+ segmentOrder: 1,
641
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
642
+ workoutSteps: [
643
+ {
644
+ type: "ExecutableStepDTO",
645
+ stepOrder: 1,
646
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
647
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
648
+ endConditionValue: 300.0,
649
+ targetType: {
650
+ workoutTargetTypeId: 1,
651
+ workoutTargetTypeKey: "no.target",
652
+ },
653
+ },
654
+ {
655
+ type: "ExecutableStepDTO",
656
+ stepOrder: 2,
657
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
658
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
659
+ endConditionValue: 1800.0,
660
+ targetType: {
661
+ workoutTargetTypeId: 1,
662
+ workoutTargetTypeKey: "no.target",
663
+ },
664
+ },
665
+ {
666
+ type: "ExecutableStepDTO",
667
+ stepOrder: 3,
668
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
669
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
670
+ endConditionValue: 300.0,
671
+ targetType: {
672
+ workoutTargetTypeId: 1,
673
+ workoutTargetTypeKey: "no.target",
674
+ },
675
+ },
676
+ ],
677
+ },
678
+ ],
679
+ },
680
+ "interval-running": {
681
+ workoutName: "Interval Running",
682
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
683
+ workoutSegments: [
684
+ {
685
+ segmentOrder: 1,
686
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
687
+ workoutSteps: [
688
+ {
689
+ type: "ExecutableStepDTO",
690
+ stepOrder: 1,
691
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
692
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
693
+ endConditionValue: 600.0,
694
+ targetType: {
695
+ workoutTargetTypeId: 1,
696
+ workoutTargetTypeKey: "no.target",
697
+ },
698
+ },
699
+ {
700
+ type: "RepeatGroupDTO",
701
+ stepOrder: 2,
702
+ numberOfIterations: 6,
703
+ workoutSteps: [
704
+ {
705
+ type: "ExecutableStepDTO",
706
+ stepOrder: 1,
707
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
708
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
709
+ endConditionValue: 60.0,
710
+ targetType: {
711
+ workoutTargetTypeId: 4,
712
+ workoutTargetTypeKey: "heart.rate.zone",
713
+ },
714
+ zoneNumber: 5,
715
+ },
716
+ {
717
+ type: "ExecutableStepDTO",
718
+ stepOrder: 2,
719
+ stepType: { stepTypeId: 4, stepTypeKey: "recovery" },
720
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
721
+ endConditionValue: 90.0,
722
+ targetType: {
723
+ workoutTargetTypeId: 4,
724
+ workoutTargetTypeKey: "heart.rate.zone",
725
+ },
726
+ zoneNumber: 2,
727
+ },
728
+ ],
729
+ },
730
+ {
731
+ type: "ExecutableStepDTO",
732
+ stepOrder: 3,
733
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
734
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
735
+ endConditionValue: 600.0,
736
+ targetType: {
737
+ workoutTargetTypeId: 1,
738
+ workoutTargetTypeKey: "no.target",
739
+ },
740
+ },
741
+ ],
742
+ },
743
+ ],
744
+ },
745
+ "tempo-run": {
746
+ workoutName: "Tempo Run",
747
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
748
+ workoutSegments: [
749
+ {
750
+ segmentOrder: 1,
751
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
752
+ workoutSteps: [
753
+ {
754
+ type: "ExecutableStepDTO",
755
+ stepOrder: 1,
756
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
757
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
758
+ endConditionValue: 600.0,
759
+ targetType: {
760
+ workoutTargetTypeId: 4,
761
+ workoutTargetTypeKey: "heart.rate.zone",
762
+ },
763
+ zoneNumber: 2,
764
+ },
765
+ {
766
+ type: "ExecutableStepDTO",
767
+ stepOrder: 2,
768
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
769
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
770
+ endConditionValue: 1200.0,
771
+ targetType: {
772
+ workoutTargetTypeId: 4,
773
+ workoutTargetTypeKey: "heart.rate.zone",
774
+ },
775
+ zoneNumber: 4,
776
+ },
777
+ {
778
+ type: "ExecutableStepDTO",
779
+ stepOrder: 3,
780
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
781
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
782
+ endConditionValue: 600.0,
783
+ targetType: {
784
+ workoutTargetTypeId: 4,
785
+ workoutTargetTypeKey: "heart.rate.zone",
786
+ },
787
+ zoneNumber: 2,
788
+ },
789
+ ],
790
+ },
791
+ ],
792
+ },
793
+ "strength-circuit": {
794
+ workoutName: "Strength Circuit",
795
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
796
+ workoutSegments: [
797
+ {
798
+ segmentOrder: 1,
799
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
800
+ workoutSteps: [
801
+ {
802
+ type: "ExecutableStepDTO",
803
+ stepOrder: 1,
804
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
805
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
806
+ endConditionValue: 300.0,
807
+ targetType: {
808
+ workoutTargetTypeId: 1,
809
+ workoutTargetTypeKey: "no.target",
810
+ },
811
+ },
812
+ {
813
+ type: "RepeatGroupDTO",
814
+ stepOrder: 2,
815
+ numberOfIterations: 3,
816
+ workoutSteps: [
817
+ {
818
+ type: "ExecutableStepDTO",
819
+ stepOrder: 1,
820
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
821
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
822
+ endConditionValue: 40.0,
823
+ targetType: {
824
+ workoutTargetTypeId: 1,
825
+ workoutTargetTypeKey: "no.target",
826
+ },
827
+ description: "Exercise (e.g. push-ups, squats, rows)",
828
+ },
829
+ {
830
+ type: "ExecutableStepDTO",
831
+ stepOrder: 2,
832
+ stepType: { stepTypeId: 5, stepTypeKey: "rest" },
833
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
834
+ endConditionValue: 20.0,
835
+ targetType: {
836
+ workoutTargetTypeId: 1,
837
+ workoutTargetTypeKey: "no.target",
838
+ },
839
+ },
840
+ ],
841
+ },
842
+ {
843
+ type: "ExecutableStepDTO",
844
+ stepOrder: 3,
845
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
846
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
847
+ endConditionValue: 300.0,
848
+ targetType: {
849
+ workoutTargetTypeId: 1,
850
+ workoutTargetTypeKey: "no.target",
851
+ },
852
+ },
853
+ ],
854
+ },
855
+ ],
856
+ },
857
+ };
858
+ const WORKOUT_STRUCTURE_REFERENCE = `# Garmin Connect Workout JSON Structure Reference
859
+
860
+ ## Top-level fields
861
+ - workoutName: string (required)
862
+ - sportType: { sportTypeId: number, sportTypeKey: string } (required)
863
+ - IDs: 1=running, 2=cycling, 3=swimming, 4=walking, 5=multi, 6=fitness_equipment, 7=hiking
864
+ - workoutSegments: array of segment objects (required)
865
+ - description: string (optional)
866
+
867
+ ## Segment fields
868
+ - segmentOrder: number (1-based, required)
869
+ - sportType: same as top-level (required)
870
+ - workoutSteps: array of step objects (required)
871
+
872
+ ## Step types
873
+
874
+ ### ExecutableStepDTO (regular steps)
875
+ - type: "ExecutableStepDTO" (required)
876
+ - stepOrder: number (1-based within the containing steps array)
877
+ - stepType: { stepTypeId: number, stepTypeKey: string }
878
+ - 1="warmup", 2="cooldown", 3="interval", 4="recovery", 5="rest"
879
+ - endCondition: { conditionTypeId: number, conditionTypeKey: string }
880
+ - 1="distance" (endConditionValue in meters)
881
+ - 2="time" (endConditionValue in seconds)
882
+ - 7="lap.button" (press lap button; no endConditionValue needed)
883
+ - endConditionValue: number (required for distance/time conditions)
884
+ - targetType: { workoutTargetTypeId: number, workoutTargetTypeKey: string }
885
+ - 1="no.target"
886
+ - 2="speed" — use targetValueOne/targetValueTwo (m/s)
887
+ - 4="heart.rate.zone" — use zoneNumber (1-5), NOT targetValueOne/targetValueTwo
888
+ - 6="cadence" — use targetValueOne/targetValueTwo (steps per minute)
889
+ - 11="power.zone" — use zoneNumber
890
+ - zoneNumber: number 1-5 (for heart.rate.zone or power.zone targets only)
891
+ - targetValueOne: number (lower bound for speed/cadence ranges)
892
+ - targetValueTwo: number (upper bound for speed/cadence ranges)
893
+ - description: string (optional, displayed on device)
894
+
895
+ ### RepeatGroupDTO (repeat blocks)
896
+ - type: "RepeatGroupDTO" (required)
897
+ - stepOrder: number (1-based within the containing steps array)
898
+ - numberOfIterations: number (how many times to repeat)
899
+ - workoutSteps: array of ExecutableStepDTO (the steps to repeat)
900
+ - stepOrder within this array is 1-based and independent of the parent
901
+
902
+ ## Notes
903
+ - NEVER use targetValueOne/targetValueTwo for heart rate zones — use zoneNumber instead.
904
+ Using targetValueOne/targetValueTwo with heart.rate.zone target type causes Garmin to
905
+ misinterpret the values as pace (m/s), resulting in impossible paces like ~11 sec/mile.
906
+ - RepeatGroupDTO cannot be nested inside another RepeatGroupDTO.
907
+ - All stepOrder values within the same array must be sequential starting from 1.
908
+ `;
909
+ export function registerResources(server) {
910
+ for (const [name, template] of Object.entries(WORKOUT_TEMPLATES)) {
911
+ const uri = `workout://templates/${name}`;
912
+ server.resource(name, uri, async (resourceUri) => ({
913
+ contents: [
914
+ {
915
+ uri: resourceUri.href,
916
+ mimeType: "application/json",
917
+ text: JSON.stringify(template, null, 2),
918
+ },
919
+ ],
920
+ }));
921
+ }
922
+ server.resource("workout-structure-reference", "workout://reference/structure", async (resourceUri) => ({
923
+ contents: [
924
+ {
925
+ uri: resourceUri.href,
926
+ mimeType: "text/markdown",
927
+ text: WORKOUT_STRUCTURE_REFERENCE,
928
+ },
929
+ ],
930
+ }));
931
+ }
568
932
  /**
569
933
  * Minimal zip extraction — finds the first .fit file using the central
570
934
  * directory (which always has correct sizes, unlike local headers that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etweisberg/garmin-connect-mcp",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "MCP server for Garmin Connect — access activities, metrics, and FIT files via Claude Code",
5
5
  "type": "module",
6
6
  "bin": {