@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 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
  ```
@@ -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
- // Bootstrap: get a recent activityId
568
+ registerResources(server);
569
+ // ── Run resource tests (no session required) ────────────────────────
570
+ console.log("── Resources (no session required) ──\n");
571
+ let passed = 0;
572
+ let failed = 0;
573
+ for (const test of resourceTests) {
574
+ const start = Date.now();
575
+ try {
576
+ await test.run(server);
577
+ const ms = Date.now() - start;
578
+ console.log(` PASS ${test.name} (${ms}ms)`);
579
+ passed++;
580
+ }
581
+ catch (e) {
582
+ const ms = Date.now() - start;
583
+ const msg = e instanceof Error ? e.message : String(e);
584
+ const short = msg.length > 120 ? msg.slice(0, 120) + "..." : msg;
585
+ console.log(` FAIL ${test.name} (${ms}ms) — ${short}`);
586
+ failed++;
587
+ }
588
+ }
589
+ // ── Bootstrap: get a recent activityId ─────────────────────────────
590
+ console.log("\n── Integration tests (session required) ──\n");
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
- import { inflateRawSync } from "node:zlib";
629
+ // ── Workout Templates (MCP Resources) ──────────────────────────────────────
630
+ // Templates adapted from Taxuspt/garmin_mcp (MIT License, Copyright (c) 2025 Alexandre Domingues)
631
+ // https://github.com/Taxuspt/garmin_mcp/blob/main/src/garmin_mcp/workout_templates.py
632
+ const WORKOUT_TEMPLATES = {
633
+ "simple-run": {
634
+ workoutName: "Simple Run",
635
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
636
+ workoutSegments: [
637
+ {
638
+ segmentOrder: 1,
639
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
640
+ workoutSteps: [
641
+ {
642
+ type: "ExecutableStepDTO",
643
+ stepOrder: 1,
644
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
645
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
646
+ endConditionValue: 300.0,
647
+ targetType: {
648
+ workoutTargetTypeId: 1,
649
+ workoutTargetTypeKey: "no.target",
650
+ },
651
+ },
652
+ {
653
+ type: "ExecutableStepDTO",
654
+ stepOrder: 2,
655
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
656
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
657
+ endConditionValue: 1800.0,
658
+ targetType: {
659
+ workoutTargetTypeId: 1,
660
+ workoutTargetTypeKey: "no.target",
661
+ },
662
+ },
663
+ {
664
+ type: "ExecutableStepDTO",
665
+ stepOrder: 3,
666
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
667
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
668
+ endConditionValue: 300.0,
669
+ targetType: {
670
+ workoutTargetTypeId: 1,
671
+ workoutTargetTypeKey: "no.target",
672
+ },
673
+ },
674
+ ],
675
+ },
676
+ ],
677
+ },
678
+ "interval-running": {
679
+ workoutName: "Interval Running",
680
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
681
+ workoutSegments: [
682
+ {
683
+ segmentOrder: 1,
684
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
685
+ workoutSteps: [
686
+ {
687
+ type: "ExecutableStepDTO",
688
+ stepOrder: 1,
689
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
690
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
691
+ endConditionValue: 600.0,
692
+ targetType: {
693
+ workoutTargetTypeId: 1,
694
+ workoutTargetTypeKey: "no.target",
695
+ },
696
+ },
697
+ {
698
+ type: "RepeatGroupDTO",
699
+ stepOrder: 2,
700
+ numberOfIterations: 6,
701
+ workoutSteps: [
702
+ {
703
+ type: "ExecutableStepDTO",
704
+ stepOrder: 1,
705
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
706
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
707
+ endConditionValue: 60.0,
708
+ targetType: {
709
+ workoutTargetTypeId: 4,
710
+ workoutTargetTypeKey: "heart.rate.zone",
711
+ },
712
+ zoneNumber: 5,
713
+ },
714
+ {
715
+ type: "ExecutableStepDTO",
716
+ stepOrder: 2,
717
+ stepType: { stepTypeId: 4, stepTypeKey: "recovery" },
718
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
719
+ endConditionValue: 90.0,
720
+ targetType: {
721
+ workoutTargetTypeId: 4,
722
+ workoutTargetTypeKey: "heart.rate.zone",
723
+ },
724
+ zoneNumber: 2,
725
+ },
726
+ ],
727
+ },
728
+ {
729
+ type: "ExecutableStepDTO",
730
+ stepOrder: 3,
731
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
732
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
733
+ endConditionValue: 600.0,
734
+ targetType: {
735
+ workoutTargetTypeId: 1,
736
+ workoutTargetTypeKey: "no.target",
737
+ },
738
+ },
739
+ ],
740
+ },
741
+ ],
742
+ },
743
+ "tempo-run": {
744
+ workoutName: "Tempo Run",
745
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
746
+ workoutSegments: [
747
+ {
748
+ segmentOrder: 1,
749
+ sportType: { sportTypeId: 1, sportTypeKey: "running" },
750
+ workoutSteps: [
751
+ {
752
+ type: "ExecutableStepDTO",
753
+ stepOrder: 1,
754
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
755
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
756
+ endConditionValue: 600.0,
757
+ targetType: {
758
+ workoutTargetTypeId: 4,
759
+ workoutTargetTypeKey: "heart.rate.zone",
760
+ },
761
+ zoneNumber: 2,
762
+ },
763
+ {
764
+ type: "ExecutableStepDTO",
765
+ stepOrder: 2,
766
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
767
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
768
+ endConditionValue: 1200.0,
769
+ targetType: {
770
+ workoutTargetTypeId: 4,
771
+ workoutTargetTypeKey: "heart.rate.zone",
772
+ },
773
+ zoneNumber: 4,
774
+ },
775
+ {
776
+ type: "ExecutableStepDTO",
777
+ stepOrder: 3,
778
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
779
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
780
+ endConditionValue: 600.0,
781
+ targetType: {
782
+ workoutTargetTypeId: 4,
783
+ workoutTargetTypeKey: "heart.rate.zone",
784
+ },
785
+ zoneNumber: 2,
786
+ },
787
+ ],
788
+ },
789
+ ],
790
+ },
791
+ "strength-circuit": {
792
+ workoutName: "Strength Circuit",
793
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
794
+ workoutSegments: [
795
+ {
796
+ segmentOrder: 1,
797
+ sportType: { sportTypeId: 6, sportTypeKey: "fitness_equipment" },
798
+ workoutSteps: [
799
+ {
800
+ type: "ExecutableStepDTO",
801
+ stepOrder: 1,
802
+ stepType: { stepTypeId: 1, stepTypeKey: "warmup" },
803
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
804
+ endConditionValue: 300.0,
805
+ targetType: {
806
+ workoutTargetTypeId: 1,
807
+ workoutTargetTypeKey: "no.target",
808
+ },
809
+ },
810
+ {
811
+ type: "RepeatGroupDTO",
812
+ stepOrder: 2,
813
+ numberOfIterations: 3,
814
+ workoutSteps: [
815
+ {
816
+ type: "ExecutableStepDTO",
817
+ stepOrder: 1,
818
+ stepType: { stepTypeId: 3, stepTypeKey: "interval" },
819
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
820
+ endConditionValue: 40.0,
821
+ targetType: {
822
+ workoutTargetTypeId: 1,
823
+ workoutTargetTypeKey: "no.target",
824
+ },
825
+ description: "Exercise (e.g. push-ups, squats, rows)",
826
+ },
827
+ {
828
+ type: "ExecutableStepDTO",
829
+ stepOrder: 2,
830
+ stepType: { stepTypeId: 5, stepTypeKey: "rest" },
831
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
832
+ endConditionValue: 20.0,
833
+ targetType: {
834
+ workoutTargetTypeId: 1,
835
+ workoutTargetTypeKey: "no.target",
836
+ },
837
+ },
838
+ ],
839
+ },
840
+ {
841
+ type: "ExecutableStepDTO",
842
+ stepOrder: 3,
843
+ stepType: { stepTypeId: 2, stepTypeKey: "cooldown" },
844
+ endCondition: { conditionTypeId: 2, conditionTypeKey: "time" },
845
+ endConditionValue: 300.0,
846
+ targetType: {
847
+ workoutTargetTypeId: 1,
848
+ workoutTargetTypeKey: "no.target",
849
+ },
850
+ },
851
+ ],
852
+ },
853
+ ],
854
+ },
855
+ };
856
+ const WORKOUT_STRUCTURE_REFERENCE = `# Garmin Connect Workout JSON Structure Reference
857
+
858
+ ## Top-level fields
859
+ - workoutName: string (required)
860
+ - sportType: { sportTypeId: number, sportTypeKey: string } (required)
861
+ - IDs: 1=running, 2=cycling, 3=swimming, 4=walking, 5=multi, 6=fitness_equipment, 7=hiking
862
+ - workoutSegments: array of segment objects (required)
863
+ - description: string (optional)
864
+
865
+ ## Segment fields
866
+ - segmentOrder: number (1-based, required)
867
+ - sportType: same as top-level (required)
868
+ - workoutSteps: array of step objects (required)
869
+
870
+ ## Step types
871
+
872
+ ### ExecutableStepDTO (regular steps)
873
+ - type: "ExecutableStepDTO" (required)
874
+ - stepOrder: number (1-based within the containing steps array)
875
+ - stepType: { stepTypeId: number, stepTypeKey: string }
876
+ - 1="warmup", 2="cooldown", 3="interval", 4="recovery", 5="rest"
877
+ - endCondition: { conditionTypeId: number, conditionTypeKey: string }
878
+ - 1="distance" (endConditionValue in meters)
879
+ - 2="time" (endConditionValue in seconds)
880
+ - 7="lap.button" (press lap button; no endConditionValue needed)
881
+ - endConditionValue: number (required for distance/time conditions)
882
+ - targetType: { workoutTargetTypeId: number, workoutTargetTypeKey: string }
883
+ - 1="no.target"
884
+ - 2="speed" — use targetValueOne/targetValueTwo (m/s)
885
+ - 4="heart.rate.zone" — use zoneNumber (1-5), NOT targetValueOne/targetValueTwo
886
+ - 6="cadence" — use targetValueOne/targetValueTwo (steps per minute)
887
+ - 11="power.zone" — use zoneNumber
888
+ - zoneNumber: number 1-5 (for heart.rate.zone or power.zone targets only)
889
+ - targetValueOne: number (lower bound for speed/cadence ranges)
890
+ - targetValueTwo: number (upper bound for speed/cadence ranges)
891
+ - description: string (optional, displayed on device)
892
+
893
+ ### RepeatGroupDTO (repeat blocks)
894
+ - type: "RepeatGroupDTO" (required)
895
+ - stepOrder: number (1-based within the containing steps array)
896
+ - numberOfIterations: number (how many times to repeat)
897
+ - workoutSteps: array of ExecutableStepDTO (the steps to repeat)
898
+ - stepOrder within this array is 1-based and independent of the parent
899
+
900
+ ## Notes
901
+ - NEVER use targetValueOne/targetValueTwo for heart rate zones — use zoneNumber instead.
902
+ Using targetValueOne/targetValueTwo with heart.rate.zone target type causes Garmin to
903
+ misinterpret the values as pace (m/s), resulting in impossible paces like ~11 sec/mile.
904
+ - RepeatGroupDTO cannot be nested inside another RepeatGroupDTO.
905
+ - All stepOrder values within the same array must be sequential starting from 1.
906
+ `;
907
+ export function registerResources(server) {
908
+ for (const [name, template] of Object.entries(WORKOUT_TEMPLATES)) {
909
+ const uri = `workout://templates/${name}`;
910
+ server.resource(name, uri, async (resourceUri) => ({
911
+ contents: [
912
+ {
913
+ uri: resourceUri.href,
914
+ mimeType: "application/json",
915
+ text: JSON.stringify(template, null, 2),
916
+ },
917
+ ],
918
+ }));
919
+ }
920
+ server.resource("workout-structure-reference", "workout://reference/structure", async (resourceUri) => ({
921
+ contents: [
922
+ {
923
+ uri: resourceUri.href,
924
+ mimeType: "text/markdown",
925
+ text: WORKOUT_STRUCTURE_REFERENCE,
926
+ },
927
+ ],
928
+ }));
929
+ }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etweisberg/garmin-connect-mcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "MCP server for Garmin Connect — access activities, metrics, and FIT files via Claude Code",
5
5
  "type": "module",
6
6
  "bin": {