@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.
- package/dist/garmin-client.js +12 -0
- package/dist/index.js +2 -1
- package/dist/test.js +127 -5
- package/dist/tools.js +387 -23
- package/package.json +1 -1
package/dist/garmin-client.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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", `
|
|
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
|
|
447
|
-
End
|
|
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
|
-
|
|
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": "
|
|
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":
|
|
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
|
-
|
|
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
|