@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 +2 -1
- package/dist/test.js +127 -5
- package/dist/tools.js +384 -22
- package/package.json +1 -1
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
|
-
|
|
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", `
|
|
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
|
|
447
|
-
End
|
|
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
|
-
|
|
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": "
|
|
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":
|
|
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
|
-
|
|
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
|