@etweisberg/garmin-connect-mcp 0.1.15 → 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/README.md +29 -0
- package/dist/garmin-client.js +49 -0
- package/dist/index.js +2 -1
- package/dist/test.js +275 -5
- package/dist/tools.js +531 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,14 @@ Session cookies expire after a few hours. Re-run the login flow when they do.
|
|
|
99
99
|
| `get-body-battery` | Body battery charged/drained values |
|
|
100
100
|
| `get-hrv` | Heart rate variability data |
|
|
101
101
|
|
|
102
|
+
### Training & Recovery
|
|
103
|
+
|
|
104
|
+
| Tool | Description |
|
|
105
|
+
| ------------------------ | ------------------------------------------------ |
|
|
106
|
+
| `get-training-readiness` | Training readiness score (sleep, recovery, load) |
|
|
107
|
+
| `get-sleep-stats` | Sleep statistics over a date range |
|
|
108
|
+
| `get-hydration` | Daily hydration/water intake data |
|
|
109
|
+
|
|
102
110
|
### Weight / Records / Fitness
|
|
103
111
|
|
|
104
112
|
| Tool | Description |
|
|
@@ -108,8 +116,29 @@ Session cookies expire after a few hours. Re-run the login flow when they do.
|
|
|
108
116
|
| `get-fitness-stats` | Aggregated activity stats by type |
|
|
109
117
|
| `get-vo2max` | Latest VO2 Max estimate |
|
|
110
118
|
| `get-hr-zones-config` | Heart rate zone boundaries |
|
|
119
|
+
| `get-power-zones` | Power zone config for all sports |
|
|
111
120
|
| `get-user-profile` | User profile and settings |
|
|
112
121
|
|
|
122
|
+
### Calendar, Goals & Badges
|
|
123
|
+
|
|
124
|
+
| Tool | Description |
|
|
125
|
+
| ----------------------- | ------------------------------------------- |
|
|
126
|
+
| `get-calendar` | Monthly calendar with activities and events |
|
|
127
|
+
| `get-goals` | Active, future, or past fitness goals |
|
|
128
|
+
| `get-badges` | All earned badges/achievements |
|
|
129
|
+
| `get-badge-leaderboard` | Badge leaderboard among connections |
|
|
130
|
+
|
|
131
|
+
### Workouts
|
|
132
|
+
|
|
133
|
+
| Tool | Description |
|
|
134
|
+
| ---------------------- | -------------------------------------------------- |
|
|
135
|
+
| `list-workouts` | List saved workouts |
|
|
136
|
+
| `get-workout` | Get workout details (steps, segments) |
|
|
137
|
+
| `create-workout` | Create a new workout (warmup, intervals, cooldown) |
|
|
138
|
+
| `schedule-workout` | Schedule a workout to a date (syncs to device) |
|
|
139
|
+
| `delete-workout` | Delete a workout |
|
|
140
|
+
| `download-workout-fit` | Download a workout as a FIT file |
|
|
141
|
+
|
|
113
142
|
## Architecture
|
|
114
143
|
|
|
115
144
|
```
|
package/dist/garmin-client.js
CHANGED
|
@@ -142,6 +142,55 @@ export class GarminClient {
|
|
|
142
142
|
}
|
|
143
143
|
return Buffer.from(result.data, "base64");
|
|
144
144
|
}
|
|
145
|
+
async post(path, body) {
|
|
146
|
+
await this.init();
|
|
147
|
+
const url = `/gc-api/${path}`;
|
|
148
|
+
const csrfToken = this.csrfToken;
|
|
149
|
+
const bodyStr = JSON.stringify(body);
|
|
150
|
+
const result = await this.page.evaluate(async ({ url, csrfToken, bodyStr, }) => {
|
|
151
|
+
const resp = await fetch(url, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: {
|
|
154
|
+
"connect-csrf-token": csrfToken,
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
Accept: "application/json, */*",
|
|
157
|
+
},
|
|
158
|
+
body: bodyStr,
|
|
159
|
+
});
|
|
160
|
+
const text = await resp.text();
|
|
161
|
+
return { status: resp.status, body: text };
|
|
162
|
+
}, { url, csrfToken, bodyStr });
|
|
163
|
+
if (result.status === 204 || (result.status === 200 && !result.body)) {
|
|
164
|
+
return { noData: true, status: result.status, path };
|
|
165
|
+
}
|
|
166
|
+
if (result.status < 200 || result.status >= 300) {
|
|
167
|
+
throw new Error(`Garmin API ${result.status}: ${path} — ${result.body}`);
|
|
168
|
+
}
|
|
169
|
+
return JSON.parse(result.body);
|
|
170
|
+
}
|
|
171
|
+
async delete(path) {
|
|
172
|
+
await this.init();
|
|
173
|
+
const url = `/gc-api/${path}`;
|
|
174
|
+
const csrfToken = this.csrfToken;
|
|
175
|
+
const result = await this.page.evaluate(async ({ url, csrfToken }) => {
|
|
176
|
+
const resp = await fetch(url, {
|
|
177
|
+
method: "DELETE",
|
|
178
|
+
headers: {
|
|
179
|
+
"connect-csrf-token": csrfToken,
|
|
180
|
+
Accept: "*/*",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const text = await resp.text();
|
|
184
|
+
return { status: resp.status, body: text };
|
|
185
|
+
}, { url, csrfToken });
|
|
186
|
+
if (result.status === 204 || (result.status === 200 && !result.body)) {
|
|
187
|
+
return { noData: true, status: result.status, path };
|
|
188
|
+
}
|
|
189
|
+
if (result.status < 200 || result.status >= 300) {
|
|
190
|
+
throw new Error(`Garmin API ${result.status}: ${path} — ${result.body}`);
|
|
191
|
+
}
|
|
192
|
+
return result.body ? JSON.parse(result.body) : { success: true };
|
|
193
|
+
}
|
|
145
194
|
}
|
|
146
195
|
// Singleton client for reuse across tool calls
|
|
147
196
|
let _sharedClient = null;
|
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
|
{
|
|
@@ -304,18 +406,188 @@ const tests = [
|
|
|
304
406
|
throw new Error("no zones returned");
|
|
305
407
|
},
|
|
306
408
|
},
|
|
409
|
+
// ── Training & Recovery ─────────────────────────────────────────
|
|
410
|
+
{
|
|
411
|
+
name: "get-training-readiness",
|
|
412
|
+
run: async (server) => {
|
|
413
|
+
const result = await callTool(server, "get-training-readiness");
|
|
414
|
+
if (result.isError)
|
|
415
|
+
throw new Error(getToolText(result));
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
name: "get-sleep-stats",
|
|
420
|
+
run: async (server) => {
|
|
421
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 86400000)
|
|
422
|
+
.toISOString()
|
|
423
|
+
.slice(0, 10);
|
|
424
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
425
|
+
const result = await callTool(server, "get-sleep-stats", {
|
|
426
|
+
startDate: sevenDaysAgo,
|
|
427
|
+
endDate: today,
|
|
428
|
+
});
|
|
429
|
+
if (result.isError)
|
|
430
|
+
throw new Error(getToolText(result));
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
// ── Calendar, Goals, Badges ────────────────────────────────────────
|
|
434
|
+
{
|
|
435
|
+
name: "get-calendar",
|
|
436
|
+
run: async (server) => {
|
|
437
|
+
const result = await callTool(server, "get-calendar", {
|
|
438
|
+
year: 2026,
|
|
439
|
+
month: 2,
|
|
440
|
+
});
|
|
441
|
+
if (result.isError)
|
|
442
|
+
throw new Error(getToolText(result));
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "get-goals",
|
|
447
|
+
run: async (server) => {
|
|
448
|
+
const result = await callTool(server, "get-goals", { status: "active" });
|
|
449
|
+
if (result.isError)
|
|
450
|
+
throw new Error(getToolText(result));
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "get-badges",
|
|
455
|
+
run: async (server) => {
|
|
456
|
+
const result = await callTool(server, "get-badges");
|
|
457
|
+
if (result.isError)
|
|
458
|
+
throw new Error(getToolText(result));
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "get-badge-leaderboard",
|
|
463
|
+
run: async (server) => {
|
|
464
|
+
const result = await callTool(server, "get-badge-leaderboard", {
|
|
465
|
+
limit: 5,
|
|
466
|
+
});
|
|
467
|
+
if (result.isError)
|
|
468
|
+
throw new Error(getToolText(result));
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
// ── Hydration & Power Zones ────────────────────────────────────────
|
|
472
|
+
{
|
|
473
|
+
name: "get-hydration",
|
|
474
|
+
run: async (server) => {
|
|
475
|
+
const result = await callTool(server, "get-hydration");
|
|
476
|
+
if (result.isError)
|
|
477
|
+
throw new Error(getToolText(result));
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "get-power-zones",
|
|
482
|
+
run: async (server) => {
|
|
483
|
+
const result = await callTool(server, "get-power-zones");
|
|
484
|
+
if (result.isError)
|
|
485
|
+
throw new Error(getToolText(result));
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
// ── Workouts ───────────────────────────────────────────────────────
|
|
489
|
+
{
|
|
490
|
+
name: "list-workouts",
|
|
491
|
+
run: async (server) => {
|
|
492
|
+
const result = await callTool(server, "list-workouts", {
|
|
493
|
+
start: 0,
|
|
494
|
+
limit: 5,
|
|
495
|
+
});
|
|
496
|
+
if (result.isError)
|
|
497
|
+
throw new Error(getToolText(result));
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: "workout CRUD (create -> schedule -> delete)",
|
|
502
|
+
run: async (server) => {
|
|
503
|
+
// Create
|
|
504
|
+
const workout = {
|
|
505
|
+
workoutName: "MCP Test Workout (safe to delete)",
|
|
506
|
+
sportType: { sportTypeId: 1, sportTypeKey: "running" },
|
|
507
|
+
workoutSegments: [
|
|
508
|
+
{
|
|
509
|
+
segmentOrder: 1,
|
|
510
|
+
sportType: { sportTypeId: 1, sportTypeKey: "running" },
|
|
511
|
+
workoutSteps: [
|
|
512
|
+
{
|
|
513
|
+
type: "ExecutableStepDTO",
|
|
514
|
+
stepOrder: 1,
|
|
515
|
+
stepType: { stepTypeId: 3, stepTypeKey: "interval" },
|
|
516
|
+
endCondition: {
|
|
517
|
+
conditionTypeId: 2,
|
|
518
|
+
conditionTypeKey: "time",
|
|
519
|
+
},
|
|
520
|
+
endConditionValue: 600,
|
|
521
|
+
targetType: {
|
|
522
|
+
workoutTargetTypeId: 1,
|
|
523
|
+
workoutTargetTypeKey: "no.target",
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
const createResult = await callTool(server, "create-workout", {
|
|
531
|
+
workout: JSON.stringify(workout),
|
|
532
|
+
});
|
|
533
|
+
if (createResult.isError)
|
|
534
|
+
throw new Error(getToolText(createResult));
|
|
535
|
+
const created = getToolJson(createResult);
|
|
536
|
+
if (!created.workoutId)
|
|
537
|
+
throw new Error("no workoutId returned");
|
|
538
|
+
const wid = String(created.workoutId);
|
|
539
|
+
// Schedule to tomorrow
|
|
540
|
+
const tomorrow = new Date(Date.now() + 86400000)
|
|
541
|
+
.toISOString()
|
|
542
|
+
.slice(0, 10);
|
|
543
|
+
const schedResult = await callTool(server, "schedule-workout", {
|
|
544
|
+
workoutId: wid,
|
|
545
|
+
date: tomorrow,
|
|
546
|
+
});
|
|
547
|
+
if (schedResult.isError)
|
|
548
|
+
throw new Error(getToolText(schedResult));
|
|
549
|
+
// Delete (cleanup)
|
|
550
|
+
const delResult = await callTool(server, "delete-workout", {
|
|
551
|
+
workoutId: wid,
|
|
552
|
+
});
|
|
553
|
+
if (delResult.isError)
|
|
554
|
+
throw new Error(getToolText(delResult));
|
|
555
|
+
},
|
|
556
|
+
},
|
|
307
557
|
];
|
|
308
558
|
// Resolved during bootstrap
|
|
309
559
|
let activityId = "";
|
|
310
560
|
async function main() {
|
|
311
561
|
console.log("garmin-connect-mcp integration tests (tool-level)\n");
|
|
312
|
-
// Set up a real MCP server with all tools registered
|
|
562
|
+
// Set up a real MCP server with all tools and resources registered
|
|
313
563
|
const server = new McpServer({
|
|
314
564
|
name: "garmin-connect-mcp-test",
|
|
315
565
|
version: "0.0.0",
|
|
316
566
|
});
|
|
317
567
|
registerTools(server);
|
|
318
|
-
|
|
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");
|
|
319
591
|
console.log("Bootstrapping...");
|
|
320
592
|
const listResult = await callTool(server, "list-activities", {
|
|
321
593
|
limit: 1,
|
|
@@ -328,8 +600,6 @@ async function main() {
|
|
|
328
600
|
process.exit(1);
|
|
329
601
|
}
|
|
330
602
|
console.log(` activityId: ${activityId}\n`);
|
|
331
|
-
let passed = 0;
|
|
332
|
-
let failed = 0;
|
|
333
603
|
for (const test of tests) {
|
|
334
604
|
const start = Date.now();
|
|
335
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 {
|
|
@@ -336,6 +337,235 @@ To authenticate, you need the Playwright MCP server installed (\`@playwright/mcp
|
|
|
336
337
|
return jsonResult(data);
|
|
337
338
|
});
|
|
338
339
|
// ══════════════════════════════════════════════════════════════════
|
|
340
|
+
// Training & Recovery
|
|
341
|
+
// ══════════════════════════════════════════════════════════════════
|
|
342
|
+
server.tool("get-training-readiness", "Get training readiness score for a date (based on sleep, recovery, training load)", {
|
|
343
|
+
date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
|
|
344
|
+
}, async ({ date }) => {
|
|
345
|
+
const client = getClient();
|
|
346
|
+
const d = date ?? todayDate();
|
|
347
|
+
const data = await client.get(`metrics-service/metrics/trainingreadiness/${d}`);
|
|
348
|
+
return jsonResult(data);
|
|
349
|
+
});
|
|
350
|
+
server.tool("get-sleep-stats", "Get sleep statistics over a date range (averages, trends)", {
|
|
351
|
+
startDate: z.string().describe("Start date YYYY-MM-DD"),
|
|
352
|
+
endDate: z.string().describe("End date YYYY-MM-DD"),
|
|
353
|
+
}, async ({ startDate, endDate }) => {
|
|
354
|
+
const client = getClient();
|
|
355
|
+
const data = await client.get(`sleep-service/stats/sleep/daily/${startDate}/${endDate}`);
|
|
356
|
+
return jsonResult(data);
|
|
357
|
+
});
|
|
358
|
+
// ══════════════════════════════════════════════════════════════════
|
|
359
|
+
// Calendar, Goals, Badges
|
|
360
|
+
// ══════════════════════════════════════════════════════════════════
|
|
361
|
+
server.tool("get-calendar", "Get monthly calendar with activities, workouts, and events", {
|
|
362
|
+
year: z.number().describe("Year (e.g. 2026)"),
|
|
363
|
+
month: z.number().describe("Month number 0-11 (0=January, 11=December)"),
|
|
364
|
+
}, async ({ year, month }) => {
|
|
365
|
+
const client = getClient();
|
|
366
|
+
const data = await client.get(`calendar-service/year/${year}/month/${month}`);
|
|
367
|
+
return jsonResult(data);
|
|
368
|
+
});
|
|
369
|
+
server.tool("get-goals", "Get fitness goals", {
|
|
370
|
+
status: z
|
|
371
|
+
.string()
|
|
372
|
+
.default("active")
|
|
373
|
+
.describe("Goal status: active, future, or past"),
|
|
374
|
+
}, async ({ status }) => {
|
|
375
|
+
const client = getClient();
|
|
376
|
+
const data = await client.get("goal-service/goal/goals", { status });
|
|
377
|
+
return jsonResult(data);
|
|
378
|
+
});
|
|
379
|
+
server.tool("get-badges", "Get all earned badges/achievements", {}, async () => {
|
|
380
|
+
const client = getClient();
|
|
381
|
+
const data = await client.get("badge-service/badge/earned");
|
|
382
|
+
return jsonResult(data);
|
|
383
|
+
});
|
|
384
|
+
server.tool("get-badge-leaderboard", "Get badge leaderboard among your connections", {
|
|
385
|
+
limit: z.number().default(25).describe("Max entries to return"),
|
|
386
|
+
}, async ({ limit }) => {
|
|
387
|
+
const client = getClient();
|
|
388
|
+
const data = await client.get("badge-service/badge/leaderboard", {
|
|
389
|
+
limit,
|
|
390
|
+
});
|
|
391
|
+
return jsonResult(data);
|
|
392
|
+
});
|
|
393
|
+
// ══════════════════════════════════════════════════════════════════
|
|
394
|
+
// Hydration & Power Zones
|
|
395
|
+
// ══════════════════════════════════════════════════════════════════
|
|
396
|
+
server.tool("get-hydration", "Get daily hydration/water intake data", {
|
|
397
|
+
date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
|
|
398
|
+
}, async ({ date }) => {
|
|
399
|
+
const client = getClient();
|
|
400
|
+
const d = date ?? todayDate();
|
|
401
|
+
const data = await client.get(`usersummary-service/usersummary/hydration/allData/${d}`);
|
|
402
|
+
return jsonResult(data);
|
|
403
|
+
});
|
|
404
|
+
server.tool("get-power-zones", "Get power zone configuration for all sports", {}, async () => {
|
|
405
|
+
const client = getClient();
|
|
406
|
+
const data = await client.get("biometric-service/powerZones/sports/all");
|
|
407
|
+
return jsonResult(data);
|
|
408
|
+
});
|
|
409
|
+
// ══════════════════════════════════════════════════════════════════
|
|
410
|
+
// Workouts (read + write)
|
|
411
|
+
// ══════════════════════════════════════════════════════════════════
|
|
412
|
+
server.tool("list-workouts", "List your saved workouts", {
|
|
413
|
+
start: z.number().default(0).describe("Pagination offset"),
|
|
414
|
+
limit: z.number().default(100).describe("Max workouts to return"),
|
|
415
|
+
}, async ({ start, limit }) => {
|
|
416
|
+
const client = getClient();
|
|
417
|
+
const data = await client.get("workout-service/workouts", {
|
|
418
|
+
start,
|
|
419
|
+
limit,
|
|
420
|
+
});
|
|
421
|
+
return jsonResult(data);
|
|
422
|
+
});
|
|
423
|
+
server.tool("get-workout", "Get a single workout by ID with full step/segment details", {
|
|
424
|
+
workoutId: z.string().describe("The workout ID"),
|
|
425
|
+
}, async ({ workoutId }) => {
|
|
426
|
+
const client = getClient();
|
|
427
|
+
const data = await client.get(`workout-service/workout/${workoutId}`);
|
|
428
|
+
return jsonResult(data);
|
|
429
|
+
});
|
|
430
|
+
server.tool("download-workout-fit", "Download a workout as a FIT file", {
|
|
431
|
+
workoutId: z.string().describe("The workout ID"),
|
|
432
|
+
outputDir: z
|
|
433
|
+
.string()
|
|
434
|
+
.default("./fit_files")
|
|
435
|
+
.describe("Directory to save the FIT file"),
|
|
436
|
+
}, async ({ workoutId, outputDir }) => {
|
|
437
|
+
const client = getClient();
|
|
438
|
+
const fitBytes = await client.getBytes(`workout-service/workout/FIT/${workoutId}`);
|
|
439
|
+
mkdirSync(outputDir, { recursive: true });
|
|
440
|
+
const outPath = join(outputDir, `workout_${workoutId}.fit`);
|
|
441
|
+
writeFileSync(outPath, fitBytes);
|
|
442
|
+
return textResult(`Downloaded workout FIT: ${outPath} (${fitBytes.length} bytes)`);
|
|
443
|
+
});
|
|
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).
|
|
454
|
+
|
|
455
|
+
Sport type IDs: 1=running, 2=cycling, 3=swimming, 4=walking, 5=multi, 6=fitness, 7=hiking.
|
|
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).
|
|
459
|
+
|
|
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:
|
|
472
|
+
{
|
|
473
|
+
"workoutName": "My Workout",
|
|
474
|
+
"sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
|
|
475
|
+
"workoutSegments": [{
|
|
476
|
+
"segmentOrder": 1,
|
|
477
|
+
"sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
|
|
478
|
+
"workoutSteps": [{
|
|
479
|
+
"type": "ExecutableStepDTO",
|
|
480
|
+
"stepOrder": 1,
|
|
481
|
+
"stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
|
|
482
|
+
"endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
|
|
483
|
+
"endConditionValue": 1200.0,
|
|
484
|
+
"targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
|
|
485
|
+
"zoneNumber": 3
|
|
486
|
+
}]
|
|
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
|
+
}]
|
|
541
|
+
}`, {
|
|
542
|
+
workout: z
|
|
543
|
+
.string()
|
|
544
|
+
.describe("JSON string of the workout object to create"),
|
|
545
|
+
}, async ({ workout }) => {
|
|
546
|
+
const client = getClient();
|
|
547
|
+
const workoutObj = JSON.parse(workout);
|
|
548
|
+
const data = await client.post("workout-service/workout", workoutObj);
|
|
549
|
+
return jsonResult(data);
|
|
550
|
+
});
|
|
551
|
+
server.tool("schedule-workout", "Schedule an existing workout to a date on your calendar. The workout will sync to your device.", {
|
|
552
|
+
workoutId: z.string().describe("The workout ID"),
|
|
553
|
+
date: z.string().describe("Date to schedule YYYY-MM-DD"),
|
|
554
|
+
}, async ({ workoutId, date }) => {
|
|
555
|
+
const client = getClient();
|
|
556
|
+
const data = await client.post(`workout-service/schedule/${workoutId}`, {
|
|
557
|
+
date,
|
|
558
|
+
});
|
|
559
|
+
return jsonResult(data);
|
|
560
|
+
});
|
|
561
|
+
server.tool("delete-workout", "Delete a workout from Garmin Connect", {
|
|
562
|
+
workoutId: z.string().describe("The workout ID to delete"),
|
|
563
|
+
}, async ({ workoutId }) => {
|
|
564
|
+
const client = getClient();
|
|
565
|
+
await client.delete(`workout-service/workout/${workoutId}`);
|
|
566
|
+
return textResult(`Workout ${workoutId} deleted`);
|
|
567
|
+
});
|
|
568
|
+
// ══════════════════════════════════════════════════════════════════
|
|
339
569
|
// Testing
|
|
340
570
|
// ══════════════════════════════════════════════════════════════════
|
|
341
571
|
server.tool("run-tests", "Returns a test plan for verifying all garmin-connect-mcp tools work. Call each tool listed and report results.", {}, async () => {
|
|
@@ -396,7 +626,307 @@ Present results as a markdown table: | Tool | Status | Notes |
|
|
|
396
626
|
Count total passed vs failed at the end.`);
|
|
397
627
|
});
|
|
398
628
|
}
|
|
399
|
-
|
|
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
|
+
}
|
|
400
930
|
/**
|
|
401
931
|
* Minimal zip extraction — finds the first .fit file using the central
|
|
402
932
|
* directory (which always has correct sizes, unlike local headers that
|