@etweisberg/garmin-connect-mcp 0.1.16 → 0.1.17

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/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,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { writeFileSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { inflateRawSync } from "node:zlib";
4
5
  import { getSharedClient, sessionExists, getSessionFile, } from "./garmin-client.js";
5
6
  function jsonResult(data) {
6
7
  return {
@@ -440,15 +441,36 @@ To authenticate, you need the Playwright MCP server installed (\`@playwright/mcp
440
441
  writeFileSync(outPath, fitBytes);
441
442
  return textResult(`Downloaded workout FIT: ${outPath} (${fitBytes.length} bytes)`);
442
443
  });
443
- server.tool("create-workout", `Create a new workout on Garmin Connect. Pass a workout JSON object with workoutName, sportType, and workoutSegments containing steps.
444
+ server.tool("create-workout", `Upload a workout from JSON data.
445
+
446
+ Creates a new workout in Garmin Connect from structured workout data.
447
+
448
+ IMPORTANT: Step types must use Garmin's DTO format:
449
+ - Use "ExecutableStepDTO" for regular steps (warmup, interval, cooldown, recovery)
450
+ - Use "RepeatGroupDTO" for repeat/interval groups with numberOfIterations
451
+
452
+ IMPORTANT: For heart rate zone targets, use "zoneNumber" (1-5), NOT targetValueOne/targetValueTwo.
453
+ targetValueOne/targetValueTwo are only for absolute value ranges (e.g. pace in m/s, power in watts).
444
454
 
445
455
  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).
456
+ Step type IDs: warmup (1), cooldown (2), interval (3), recovery (4), rest (5).
457
+ End condition IDs: distance (1, value in meters), time (2, value in seconds), open (7, no value needed).
458
+ 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
459
 
449
- Example minimal running workout:
460
+ **Available Templates:**
461
+ Instead of building workout JSON from scratch, use these MCP resources as starting points:
462
+ - workout://templates/simple-run - Basic warmup/run/cooldown structure
463
+ - workout://templates/interval-running - Interval training with repeat groups
464
+ - workout://templates/tempo-run - Tempo run with heart rate zone targets
465
+ - workout://templates/strength-circuit - Strength training circuit structure
466
+ - workout://reference/structure - Complete JSON structure reference with all fields
467
+
468
+ Access these resources using your MCP client's resource reading capability, modify the template
469
+ as needed, and pass the resulting JSON as the workout parameter.
470
+
471
+ Example workout structure with HR zone target:
450
472
  {
451
- "workoutName": "Easy 30min Run",
473
+ "workoutName": "My Workout",
452
474
  "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
453
475
  "workoutSegments": [{
454
476
  "segmentOrder": 1,
@@ -456,26 +478,66 @@ Example minimal running workout:
456
478
  "workoutSteps": [{
457
479
  "type": "ExecutableStepDTO",
458
480
  "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
481
  "stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
467
482
  "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"}
483
+ "endConditionValue": 1200.0,
484
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
485
+ "zoneNumber": 3
477
486
  }]
478
487
  }]
488
+ }
489
+
490
+ Example with RepeatGroupDTO for intervals:
491
+ {
492
+ "workoutName": "Interval Run",
493
+ "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
494
+ "workoutSegments": [{
495
+ "segmentOrder": 1,
496
+ "sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
497
+ "workoutSteps": [
498
+ {
499
+ "type": "ExecutableStepDTO",
500
+ "stepOrder": 1,
501
+ "stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"},
502
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
503
+ "endConditionValue": 600.0,
504
+ "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
505
+ },
506
+ {
507
+ "type": "RepeatGroupDTO",
508
+ "stepOrder": 2,
509
+ "numberOfIterations": 6,
510
+ "workoutSteps": [
511
+ {
512
+ "type": "ExecutableStepDTO",
513
+ "stepOrder": 1,
514
+ "stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
515
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
516
+ "endConditionValue": 60.0,
517
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
518
+ "zoneNumber": 5
519
+ },
520
+ {
521
+ "type": "ExecutableStepDTO",
522
+ "stepOrder": 2,
523
+ "stepType": {"stepTypeId": 4, "stepTypeKey": "recovery"},
524
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
525
+ "endConditionValue": 90.0,
526
+ "targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
527
+ "zoneNumber": 2
528
+ }
529
+ ]
530
+ },
531
+ {
532
+ "type": "ExecutableStepDTO",
533
+ "stepOrder": 3,
534
+ "stepType": {"stepTypeId": 2, "stepTypeKey": "cooldown"},
535
+ "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
536
+ "endConditionValue": 600.0,
537
+ "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}
538
+ }
539
+ ]
540
+ }]
479
541
  }`, {
480
542
  workout: z
481
543
  .string()
@@ -564,7 +626,307 @@ Present results as a markdown table: | Tool | Status | Notes |
564
626
  Count total passed vs failed at the end.`);
565
627
  });
566
628
  }
567
- import { inflateRawSync } from "node:zlib";
629
+ // ── Workout Templates (MCP Resources) ──────────────────────────────────────
630
+ // Templates adapted from Taxuspt/garmin_mcp (MIT License, Copyright (c) 2025 Alexandre Domingues)
631
+ // https://github.com/Taxuspt/garmin_mcp/blob/main/src/garmin_mcp/workout_templates.py
632
+ const WORKOUT_TEMPLATES = {
633
+ "simple-run": {
634
+ workoutName: "Simple Run",
635
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
636
+ workoutSegments: [
637
+ {
638
+ segmentOrder: 1,
639
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
640
+ workoutSteps: [
641
+ {
642
+ type: "ExecutableStepDTO",
643
+ stepOrder: 1,
644
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
645
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
646
+ endConditionValue: 300.0,
647
+ targetType: {
648
+ workoutTargetTypeId: 1,
649
+ workoutTargetTypeKey: "no.target",
650
+ },
651
+ },
652
+ {
653
+ type: "ExecutableStepDTO",
654
+ stepOrder: 2,
655
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
656
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
657
+ endConditionValue: 1800.0,
658
+ targetType: {
659
+ workoutTargetTypeId: 1,
660
+ workoutTargetTypeKey: "no.target",
661
+ },
662
+ },
663
+ {
664
+ type: "ExecutableStepDTO",
665
+ stepOrder: 3,
666
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
667
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
668
+ endConditionValue: 300.0,
669
+ targetType: {
670
+ workoutTargetTypeId: 1,
671
+ workoutTargetTypeKey: "no.target",
672
+ },
673
+ },
674
+ ],
675
+ },
676
+ ],
677
+ },
678
+ "interval-running": {
679
+ workoutName: "Interval Running",
680
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
681
+ workoutSegments: [
682
+ {
683
+ segmentOrder: 1,
684
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
685
+ workoutSteps: [
686
+ {
687
+ type: "ExecutableStepDTO",
688
+ stepOrder: 1,
689
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
690
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
691
+ endConditionValue: 600.0,
692
+ targetType: {
693
+ workoutTargetTypeId: 1,
694
+ workoutTargetTypeKey: "no.target",
695
+ },
696
+ },
697
+ {
698
+ type: "RepeatGroupDTO",
699
+ stepOrder: 2,
700
+ numberOfIterations: 6,
701
+ workoutSteps: [
702
+ {
703
+ type: "ExecutableStepDTO",
704
+ stepOrder: 1,
705
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
706
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
707
+ endConditionValue: 60.0,
708
+ targetType: {
709
+ workoutTargetTypeId: 4,
710
+ workoutTargetTypeKey: "heart.rate.zone",
711
+ },
712
+ zoneNumber: 5,
713
+ },
714
+ {
715
+ type: "ExecutableStepDTO",
716
+ stepOrder: 2,
717
+ stepType: { stepTypeId: 4, stepTypeKey: "recovery" },
718
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
719
+ endConditionValue: 90.0,
720
+ targetType: {
721
+ workoutTargetTypeId: 4,
722
+ workoutTargetTypeKey: "heart.rate.zone",
723
+ },
724
+ zoneNumber: 2,
725
+ },
726
+ ],
727
+ },
728
+ {
729
+ type: "ExecutableStepDTO",
730
+ stepOrder: 3,
731
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
732
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
733
+ endConditionValue: 600.0,
734
+ targetType: {
735
+ workoutTargetTypeId: 1,
736
+ workoutTargetTypeKey: "no.target",
737
+ },
738
+ },
739
+ ],
740
+ },
741
+ ],
742
+ },
743
+ "tempo-run": {
744
+ workoutName: "Tempo Run",
745
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
746
+ workoutSegments: [
747
+ {
748
+ segmentOrder: 1,
749
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
750
+ workoutSteps: [
751
+ {
752
+ type: "ExecutableStepDTO",
753
+ stepOrder: 1,
754
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
755
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
756
+ endConditionValue: 600.0,
757
+ targetType: {
758
+ workoutTargetTypeId: 4,
759
+ workoutTargetTypeKey: "heart.rate.zone",
760
+ },
761
+ zoneNumber: 2,
762
+ },
763
+ {
764
+ type: "ExecutableStepDTO",
765
+ stepOrder: 2,
766
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
767
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
768
+ endConditionValue: 1200.0,
769
+ targetType: {
770
+ workoutTargetTypeId: 4,
771
+ workoutTargetTypeKey: "heart.rate.zone",
772
+ },
773
+ zoneNumber: 4,
774
+ },
775
+ {
776
+ type: "ExecutableStepDTO",
777
+ stepOrder: 3,
778
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
779
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
780
+ endConditionValue: 600.0,
781
+ targetType: {
782
+ workoutTargetTypeId: 4,
783
+ workoutTargetTypeKey: "heart.rate.zone",
784
+ },
785
+ zoneNumber: 2,
786
+ },
787
+ ],
788
+ },
789
+ ],
790
+ },
791
+ "strength-circuit": {
792
+ workoutName: "Strength Circuit",
793
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
794
+ workoutSegments: [
795
+ {
796
+ segmentOrder: 1,
797
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
798
+ workoutSteps: [
799
+ {
800
+ type: "ExecutableStepDTO",
801
+ stepOrder: 1,
802
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
803
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
804
+ endConditionValue: 300.0,
805
+ targetType: {
806
+ workoutTargetTypeId: 1,
807
+ workoutTargetTypeKey: "no.target",
808
+ },
809
+ },
810
+ {
811
+ type: "RepeatGroupDTO",
812
+ stepOrder: 2,
813
+ numberOfIterations: 3,
814
+ workoutSteps: [
815
+ {
816
+ type: "ExecutableStepDTO",
817
+ stepOrder: 1,
818
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
819
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
820
+ endConditionValue: 40.0,
821
+ targetType: {
822
+ workoutTargetTypeId: 1,
823
+ workoutTargetTypeKey: "no.target",
824
+ },
825
+ description: "Exercise (e.g. push-ups, squats, rows)",
826
+ },
827
+ {
828
+ type: "ExecutableStepDTO",
829
+ stepOrder: 2,
830
+ stepType: { stepTypeId: 5, stepTypeKey: "rest" },
831
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
832
+ endConditionValue: 20.0,
833
+ targetType: {
834
+ workoutTargetTypeId: 1,
835
+ workoutTargetTypeKey: "no.target",
836
+ },
837
+ },
838
+ ],
839
+ },
840
+ {
841
+ type: "ExecutableStepDTO",
842
+ stepOrder: 3,
843
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
844
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
845
+ endConditionValue: 300.0,
846
+ targetType: {
847
+ workoutTargetTypeId: 1,
848
+ workoutTargetTypeKey: "no.target",
849
+ },
850
+ },
851
+ ],
852
+ },
853
+ ],
854
+ },
855
+ };
856
+ const WORKOUT_STRUCTURE_REFERENCE = `# Garmin Connect Workout JSON Structure Reference
857
+
858
+ ## Top-level fields
859
+ - workoutName: string (required)
860
+ - sportType: { sportTypeId: number, sportTypeKey: string } (required)
861
+ - IDs: 1=running, 2=cycling, 3=swimming, 4=walking, 5=multi, 6=fitness_equipment, 7=hiking
862
+ - workoutSegments: array of segment objects (required)
863
+ - description: string (optional)
864
+
865
+ ## Segment fields
866
+ - segmentOrder: number (1-based, required)
867
+ - sportType: same as top-level (required)
868
+ - workoutSteps: array of step objects (required)
869
+
870
+ ## Step types
871
+
872
+ ### ExecutableStepDTO (regular steps)
873
+ - type: "ExecutableStepDTO" (required)
874
+ - stepOrder: number (1-based within the containing steps array)
875
+ - stepType: { stepTypeId: number, stepTypeKey: string }
876
+ - 1="warmup", 2="cooldown", 3="interval", 4="recovery", 5="rest"
877
+ - endCondition: { conditionTypeId: number, conditionTypeKey: string }
878
+ - 1="distance" (endConditionValue in meters)
879
+ - 2="time" (endConditionValue in seconds)
880
+ - 7="lap.button" (press lap button; no endConditionValue needed)
881
+ - endConditionValue: number (required for distance/time conditions)
882
+ - targetType: { workoutTargetTypeId: number, workoutTargetTypeKey: string }
883
+ - 1="no.target"
884
+ - 2="speed" — use targetValueOne/targetValueTwo (m/s)
885
+ - 4="heart.rate.zone" — use zoneNumber (1-5), NOT targetValueOne/targetValueTwo
886
+ - 6="cadence" — use targetValueOne/targetValueTwo (steps per minute)
887
+ - 11="power.zone" — use zoneNumber
888
+ - zoneNumber: number 1-5 (for heart.rate.zone or power.zone targets only)
889
+ - targetValueOne: number (lower bound for speed/cadence ranges)
890
+ - targetValueTwo: number (upper bound for speed/cadence ranges)
891
+ - description: string (optional, displayed on device)
892
+
893
+ ### RepeatGroupDTO (repeat blocks)
894
+ - type: "RepeatGroupDTO" (required)
895
+ - stepOrder: number (1-based within the containing steps array)
896
+ - numberOfIterations: number (how many times to repeat)
897
+ - workoutSteps: array of ExecutableStepDTO (the steps to repeat)
898
+ - stepOrder within this array is 1-based and independent of the parent
899
+
900
+ ## Notes
901
+ - NEVER use targetValueOne/targetValueTwo for heart rate zones — use zoneNumber instead.
902
+ Using targetValueOne/targetValueTwo with heart.rate.zone target type causes Garmin to
903
+ misinterpret the values as pace (m/s), resulting in impossible paces like ~11 sec/mile.
904
+ - RepeatGroupDTO cannot be nested inside another RepeatGroupDTO.
905
+ - All stepOrder values within the same array must be sequential starting from 1.
906
+ `;
907
+ export function registerResources(server) {
908
+ for (const [name, template] of Object.entries(WORKOUT_TEMPLATES)) {
909
+ const uri = `workout://templates/${name}`;
910
+ server.resource(name, uri, async (resourceUri) => ({
911
+ contents: [
912
+ {
913
+ uri: resourceUri.href,
914
+ mimeType: "application/json",
915
+ text: JSON.stringify(template, null, 2),
916
+ },
917
+ ],
918
+ }));
919
+ }
920
+ server.resource("workout-structure-reference", "workout://reference/structure", async (resourceUri) => ({
921
+ contents: [
922
+ {
923
+ uri: resourceUri.href,
924
+ mimeType: "text/markdown",
925
+ text: WORKOUT_STRUCTURE_REFERENCE,
926
+ },
927
+ ],
928
+ }));
929
+ }
568
930
  /**
569
931
  * Minimal zip extraction — finds the first .fit file using the central
570
932
  * 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.17",
4
4
  "description": "MCP server for Garmin Connect — access activities, metrics, and FIT files via Claude Code",
5
5
  "type": "module",
6
6
  "bin": {