@etweisberg/garmin-connect-mcp 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2026 Ethan Weisberg
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as
8
+ published by the Free Software Foundation, either version 3 of the
9
+ License, or (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # garmin-connect-mcp
2
+
3
+ MCP server for Garmin Connect. Access your activities, health stats, sleep data, FIT files, and more from Claude Code or any MCP client.
4
+
5
+ ## Why This Exists
6
+
7
+ In March 2026, Garmin changed their authentication API, breaking [garth](https://github.com/matin/garth) and [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) — the two most popular libraries for accessing Garmin data programmatically. Garth has been [officially deprecated](https://github.com/matin/garth/discussions/222). Garmin added Cloudflare TLS fingerprinting that blocks all non-browser HTTP clients (Node.js `fetch`, Python `requests`, `curl`) from their API endpoints.
8
+
9
+ This project works around that by routing all API calls through a headless Playwright browser, inheriting a real Chrome TLS fingerprint. Authentication uses browser cookies captured from a manual login session.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g @etweisberg/garmin-connect-mcp
15
+ npx playwright install chromium
16
+ ```
17
+
18
+ Or register directly with Claude Code:
19
+
20
+ ```bash
21
+ claude mcp add garmin -- npx @etweisberg/garmin-connect-mcp
22
+ ```
23
+
24
+ ### Prerequisites
25
+
26
+ - Node.js 18+
27
+ - [Playwright MCP server](https://www.npmjs.com/package/@playwright/mcp) (`claude mcp add playwright -- npx @playwright/mcp@latest`) — needed for the login flow
28
+
29
+ ## Setup
30
+
31
+ ### 1. Login
32
+
33
+ In Claude Code, call the `garmin-login` tool. It will walk you through:
34
+
35
+ 1. Opening Garmin Connect in the Playwright browser
36
+ 2. Logging in manually
37
+ 3. Extracting cookies and CSRF token
38
+ 4. Saving the session to `~/.garmin-connect-mcp/session.json`
39
+
40
+ ### 2. Verify
41
+
42
+ Call the `check-session` tool to confirm authentication works.
43
+
44
+ Session cookies expire after a few hours. Re-run the login flow when they do.
45
+
46
+ ## Available Tools
47
+
48
+ ### Session & Auth
49
+ | Tool | Description |
50
+ |------|-------------|
51
+ | `garmin-login` | Returns login instructions for the Playwright MCP browser |
52
+ | `check-session` | Validates the saved session is still active |
53
+ | `run-tests` | Returns a test plan to verify all tools work |
54
+
55
+ ### Activities
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `list-activities` | List activities with pagination |
59
+ | `get-activity` | Full activity summary (distance, duration, HR, calories) |
60
+ | `get-activity-details` | Time-series metrics (HR, cadence, elevation over time) |
61
+ | `get-activity-splits` | Lap/split data |
62
+ | `get-activity-hr-zones` | Heart rate time-in-zone breakdown |
63
+ | `get-activity-polyline` | Full-resolution GPS track |
64
+ | `get-activity-weather` | Weather conditions during activity |
65
+ | `download-fit` | Download original FIT file |
66
+
67
+ ### Daily Health
68
+ | Tool | Description |
69
+ |------|-------------|
70
+ | `get-daily-summary` | Steps, calories, distance, intensity minutes |
71
+ | `get-daily-heart-rate` | Heart rate data throughout the day |
72
+ | `get-daily-stress` | Stress levels throughout the day |
73
+ | `get-daily-summary-chart` | Combined wellness chart data |
74
+ | `get-daily-intensity-minutes` | Intensity minutes for a date |
75
+ | `get-daily-movement` | Movement/activity data |
76
+ | `get-daily-respiration` | Respiration rate data |
77
+
78
+ ### Sleep / Body Battery / HRV
79
+ | Tool | Description |
80
+ |------|-------------|
81
+ | `get-sleep` | Sleep score, duration, stages, SpO2 |
82
+ | `get-body-battery` | Body battery charged/drained values |
83
+ | `get-hrv` | Heart rate variability data |
84
+
85
+ ### Weight / Records / Fitness
86
+ | Tool | Description |
87
+ |------|-------------|
88
+ | `get-weight` | Weight measurements over a date range |
89
+ | `get-personal-records` | All personal records with history |
90
+ | `get-fitness-stats` | Aggregated activity stats by type |
91
+ | `get-vo2max` | Latest VO2 Max estimate |
92
+ | `get-hr-zones-config` | Heart rate zone boundaries |
93
+ | `get-user-profile` | User profile and settings |
94
+
95
+ ## Architecture
96
+
97
+ ```
98
+ Claude Code / MCP Client
99
+ |
100
+ | MCP (stdio)
101
+ v
102
+ garmin-connect-mcp server
103
+ |
104
+ | page.evaluate(fetch(...))
105
+ v
106
+ Headless Playwright Chromium
107
+ |
108
+ | HTTPS (real Chrome TLS fingerprint)
109
+ v
110
+ connect.garmin.com/gc-api/*
111
+ ```
112
+
113
+ All API calls are made from within a headless Chromium browser context via `page.evaluate(fetch(...))`. This inherits the real Chrome TLS fingerprint, bypassing Cloudflare's detection of non-browser clients.
114
+
115
+ **Auth flow**: Cookies + CSRF token are captured from a manual browser login (via the Playwright MCP server) and stored at `~/.garmin-connect-mcp/session.json`. The headless browser loads these cookies on startup.
116
+
117
+ **Why not direct HTTP?** Cloudflare blocks Node.js `fetch`, Python `requests`, and even `curl` with a 403. Only requests from a real browser TLS stack are accepted.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ git clone https://github.com/etweisberg/garmin-connect-mcp.git
123
+ cd garmin-connect-mcp
124
+ npm install
125
+ npx playwright install chromium
126
+ npm run build
127
+ ```
128
+
129
+ ### Scripts
130
+
131
+ | Command | Description |
132
+ |---------|-------------|
133
+ | `npm run build` | Compile TypeScript |
134
+ | `npm run lint` | Run ESLint |
135
+ | `npm run format` | Format with Prettier |
136
+ | `npm run typecheck` | Type check without emitting |
137
+ | `npm test` | Run integration tests (requires valid session) |
138
+
139
+ ### Local Integration Testing
140
+
141
+ The standalone test suite (`npm test`) requires a valid Garmin session and hits the real API. Run it locally after authenticating:
142
+
143
+ ```bash
144
+ npm test
145
+ ```
146
+
147
+ ## Contributing
148
+
149
+ 1. Fork the repo and create a feature branch
150
+ 2. Make your changes
151
+ 3. Run checks:
152
+ ```bash
153
+ npm run lint
154
+ npm run format
155
+ npm run typecheck
156
+ npm run build
157
+ ```
158
+ 4. **Test via Claude Code**: The recommended way to verify your changes is through Claude Code. After building, call the `run-tests` MCP tool — it returns a test plan that exercises all 27 tools against the live Garmin API. Tell Claude to execute the plan and report results.
159
+ 5. Open a PR against `main`
160
+
161
+ CI runs lint, format check, typecheck, and build on every PR. Integration tests run locally only (they require Garmin authentication that can't safely run in CI).
162
+
163
+ ### Releasing
164
+
165
+ Releases are automated via GitHub Actions:
166
+
167
+ ```bash
168
+ # Bump version
169
+ npm version patch # or minor, major
170
+
171
+ # Push the tag
172
+ git push --follow-tags
173
+
174
+ # GitHub Actions will:
175
+ # 1. Build the package
176
+ # 2. Publish to npm with provenance
177
+ # 3. Create a GitHub Release
178
+ ```
179
+
180
+ ## License
181
+
182
+ [AGPL-3.0](LICENSE)
package/dist/auth.js ADDED
@@ -0,0 +1,80 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { getSessionDir, getSessionFile } from "./garmin-client.js";
5
+ /**
6
+ * Login flow that uses the user's real Chrome profile to bypass Cloudflare.
7
+ * Falls back to a fresh Playwright browser if Chrome profile isn't found.
8
+ */
9
+ export async function runLogin() {
10
+ let playwright;
11
+ try {
12
+ playwright = await import("playwright");
13
+ }
14
+ catch {
15
+ console.error("Playwright is required for login. Install it:\n" +
16
+ " npm install playwright && npx playwright install chromium");
17
+ process.exit(1);
18
+ }
19
+ // Try to find Chrome user data dir for a real browser fingerprint
20
+ const chromeDataDir = join(homedir(), "Library/Application Support/Google/Chrome");
21
+ const useChromeProfile = existsSync(chromeDataDir);
22
+ let browser;
23
+ let context;
24
+ if (useChromeProfile) {
25
+ console.error("Launching with your Chrome profile (bypasses Cloudflare)...");
26
+ console.error(" Note: Close all Chrome windows first, or this will fail.\n");
27
+ browser = await playwright.chromium.launchPersistentContext(chromeDataDir, {
28
+ headless: false,
29
+ channel: "chrome",
30
+ });
31
+ context = browser;
32
+ }
33
+ else {
34
+ console.error("Launching Playwright Chromium...");
35
+ browser = await playwright.chromium.launch({ headless: false });
36
+ context = await browser.newContext();
37
+ }
38
+ const page = useChromeProfile
39
+ ? await context.newPage()
40
+ : await context.newPage();
41
+ console.error("Opening Garmin Connect...");
42
+ await page.goto("https://connect.garmin.com/app/activities");
43
+ console.error("\n Log in to Garmin Connect in the browser window.\n" +
44
+ " Once you see your activities list, press Enter here...\n");
45
+ await new Promise((resolve) => {
46
+ process.stdin.once("data", () => resolve());
47
+ });
48
+ // Extract CSRF token from <meta name="csrf-token">
49
+ const csrf = await page.evaluate("() => document.querySelector('meta[name=\"csrf-token\"]')?.content ?? null");
50
+ if (!csrf) {
51
+ console.error("Warning: could not find CSRF token. Make sure you're on the activities page.");
52
+ }
53
+ const cookies = useChromeProfile
54
+ ? await context.cookies()
55
+ : await context.cookies();
56
+ if (useChromeProfile) {
57
+ await context.close();
58
+ }
59
+ else {
60
+ await context.close();
61
+ await browser.close();
62
+ }
63
+ const sessionData = {
64
+ csrf_token: csrf ?? "",
65
+ cookies: cookies
66
+ .filter((c) => c.domain && c.domain.includes("garmin"))
67
+ .map((c) => ({
68
+ name: c.name,
69
+ value: c.value,
70
+ domain: c.domain,
71
+ })),
72
+ };
73
+ const dir = getSessionDir();
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ const file = getSessionFile();
76
+ writeFileSync(file, JSON.stringify(sessionData, null, 2), { mode: 0o600 });
77
+ console.error(`\nSession saved to ${file}`);
78
+ console.error(`CSRF token: ${(csrf ?? "").substring(0, 20)}...`);
79
+ console.error(`Cookies: ${sessionData.cookies.length} saved`);
80
+ }
@@ -0,0 +1,171 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const SESSION_DIR = join(homedir(), ".garmin-connect-mcp");
5
+ const SESSION_FILE = join(SESSION_DIR, "session.json");
6
+ export function getSessionDir() {
7
+ return SESSION_DIR;
8
+ }
9
+ export function getSessionFile() {
10
+ return SESSION_FILE;
11
+ }
12
+ export function sessionExists() {
13
+ return existsSync(SESSION_FILE);
14
+ }
15
+ function loadSession() {
16
+ if (!existsSync(SESSION_FILE)) {
17
+ throw new Error(`No saved session found at ${SESSION_FILE}. Run: npx garmin-connect-mcp login`);
18
+ }
19
+ return JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
20
+ }
21
+ /**
22
+ * Garmin Connect API client that routes requests through a headless Playwright
23
+ * browser to bypass Cloudflare TLS fingerprinting.
24
+ *
25
+ * The browser navigates to connect.garmin.com once (with saved cookies),
26
+ * then all API calls are made via page.evaluate(fetch(...)) from the
27
+ * browser context — inheriting the real Chrome TLS fingerprint.
28
+ */
29
+ export class GarminClient {
30
+ page = null; // playwright Page
31
+ browser = null;
32
+ csrfToken;
33
+ cookies;
34
+ initialized = false;
35
+ displayName = null;
36
+ constructor(sessionPath) {
37
+ const session = sessionPath
38
+ ? JSON.parse(readFileSync(sessionPath, "utf-8"))
39
+ : loadSession();
40
+ this.csrfToken = session.csrf_token;
41
+ this.cookies = session.cookies;
42
+ }
43
+ async init() {
44
+ if (this.initialized)
45
+ return;
46
+ let playwright;
47
+ try {
48
+ playwright = await import("playwright");
49
+ }
50
+ catch {
51
+ throw new Error("Playwright is required. Install: npm install playwright && npx playwright install chromium");
52
+ }
53
+ this.browser = await playwright.chromium.launch({ headless: true });
54
+ const context = await this.browser.newContext();
55
+ // Load saved cookies into the browser context
56
+ await context.addCookies(this.cookies.map((c) => ({
57
+ name: c.name,
58
+ value: c.value,
59
+ domain: c.domain,
60
+ path: "/",
61
+ })));
62
+ this.page = await context.newPage();
63
+ // Navigate to Garmin Connect to establish the Cloudflare session
64
+ await this.page.goto("https://connect.garmin.com/app/activities", {
65
+ waitUntil: "domcontentloaded",
66
+ timeout: 30000,
67
+ });
68
+ // Verify we got the CSRF token (page loaded successfully)
69
+ const csrf = await this.page.evaluate("() => document.querySelector('meta[name=\"csrf-token\"]')?.content ?? null");
70
+ if (csrf) {
71
+ this.csrfToken = csrf;
72
+ }
73
+ this.initialized = true;
74
+ console.error("Garmin browser session initialized");
75
+ }
76
+ async getDisplayName() {
77
+ if (this.displayName)
78
+ return this.displayName;
79
+ const settings = (await this.get("userprofile-service/userprofile/settings"));
80
+ this.displayName = settings.displayName;
81
+ if (!this.displayName) {
82
+ throw new Error("Could not resolve displayName from userprofile settings");
83
+ }
84
+ return this.displayName;
85
+ }
86
+ async close() {
87
+ if (this.browser) {
88
+ await this.browser.close();
89
+ this.browser = null;
90
+ this.page = null;
91
+ this.initialized = false;
92
+ }
93
+ }
94
+ async get(path, params) {
95
+ await this.init();
96
+ let url = `/gc-api/${path}`;
97
+ if (params) {
98
+ const qs = new URLSearchParams();
99
+ for (const [k, v] of Object.entries(params)) {
100
+ qs.set(k, String(v));
101
+ }
102
+ url += `?${qs.toString()}`;
103
+ }
104
+ const csrfToken = this.csrfToken;
105
+ const result = await this.page.evaluate(async ({ url, csrfToken }) => {
106
+ const resp = await fetch(url, {
107
+ headers: {
108
+ "connect-csrf-token": csrfToken,
109
+ Accept: "*/*",
110
+ },
111
+ });
112
+ const text = await resp.text();
113
+ return { status: resp.status, body: text };
114
+ }, { url, csrfToken });
115
+ if (result.status === 204 || (result.status === 200 && !result.body)) {
116
+ return { noData: true, status: result.status, path };
117
+ }
118
+ if (result.status !== 200) {
119
+ throw new Error(`Garmin API ${result.status}: ${path} — ${result.body}`);
120
+ }
121
+ return JSON.parse(result.body);
122
+ }
123
+ async getBytes(path) {
124
+ await this.init();
125
+ const url = `/gc-api/${path}`;
126
+ const csrfToken = this.csrfToken;
127
+ const result = await this.page.evaluate(async ({ url, csrfToken }) => {
128
+ const resp = await fetch(url, {
129
+ headers: {
130
+ "connect-csrf-token": csrfToken,
131
+ Accept: "*/*",
132
+ },
133
+ });
134
+ if (!resp.ok) {
135
+ return { status: resp.status, error: await resp.text(), data: null };
136
+ }
137
+ const buf = await resp.arrayBuffer();
138
+ // Convert to base64 to pass through page.evaluate boundary
139
+ const bytes = new Uint8Array(buf);
140
+ let binary = "";
141
+ for (let i = 0; i < bytes.length; i++) {
142
+ binary += String.fromCharCode(bytes[i]);
143
+ }
144
+ return { status: resp.status, error: null, data: btoa(binary) };
145
+ }, { url, csrfToken });
146
+ if (result.status !== 200 || !result.data) {
147
+ throw new Error(`Garmin API ${result.status}: ${path} — ${result.error ?? ""}`);
148
+ }
149
+ return Buffer.from(result.data, "base64");
150
+ }
151
+ }
152
+ // Singleton client for reuse across tool calls
153
+ let _sharedClient = null;
154
+ export function getSharedClient() {
155
+ if (!_sharedClient) {
156
+ _sharedClient = new GarminClient();
157
+ }
158
+ return _sharedClient;
159
+ }
160
+ // Clean up on process exit
161
+ process.on("exit", () => {
162
+ _sharedClient?.close();
163
+ });
164
+ process.on("SIGINT", () => {
165
+ _sharedClient?.close();
166
+ process.exit(0);
167
+ });
168
+ process.on("SIGTERM", () => {
169
+ _sharedClient?.close();
170
+ process.exit(0);
171
+ });
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerTools } from "./tools.js";
5
+ async function startMcpServer() {
6
+ const server = new McpServer({
7
+ name: "garmin-connect-mcp",
8
+ version: "0.1.0",
9
+ });
10
+ registerTools(server);
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ console.error("garmin-connect-mcp server running on stdio");
14
+ }
15
+ async function main() {
16
+ const command = process.argv[2];
17
+ if (command === "login") {
18
+ const { runLogin } = await import("./auth.js");
19
+ await runLogin();
20
+ }
21
+ else {
22
+ await startMcpServer();
23
+ }
24
+ }
25
+ main().catch((err) => {
26
+ console.error("Fatal error:", err);
27
+ process.exit(1);
28
+ });
package/dist/test.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Integration test suite for garmin-connect-mcp.
3
+ * Requires a valid session at ~/.garmin-connect-mcp/session.json.
4
+ * Run: npm test
5
+ */
6
+ import { GarminClient } from "./garmin-client.js";
7
+ const tests = [
8
+ // ── Session / Profile ──────────────────────────────────────────────
9
+ {
10
+ name: "check-session",
11
+ run: ({ client }) => client.get("userprofile-service/userprofile/user-settings/"),
12
+ },
13
+ {
14
+ name: "get-user-profile",
15
+ run: ({ client }) => client.get("userprofile-service/userprofile/user-settings/"),
16
+ },
17
+ // ── Activities ─────────────────────────────────────────────────────
18
+ {
19
+ name: "list-activities",
20
+ run: ({ client }) => client.get("activitylist-service/activities/search/activities", {
21
+ limit: 2,
22
+ start: 0,
23
+ }),
24
+ },
25
+ {
26
+ name: "get-activity",
27
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}`),
28
+ },
29
+ {
30
+ name: "get-activity-details",
31
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/details`, {
32
+ maxChartSize: 100,
33
+ maxPolylineSize: 0,
34
+ maxHeatMapSize: 100,
35
+ }),
36
+ },
37
+ {
38
+ name: "get-activity-splits",
39
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/splits`),
40
+ },
41
+ {
42
+ name: "get-activity-hr-zones",
43
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/hrTimeInZones`),
44
+ },
45
+ {
46
+ name: "get-activity-polyline",
47
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/polyline/full-resolution/`),
48
+ },
49
+ {
50
+ name: "get-activity-weather",
51
+ run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/weather`),
52
+ },
53
+ {
54
+ name: "download-fit",
55
+ run: ({ client, activityId }) => client.getBytes(`download-service/files/activity/${activityId}`),
56
+ },
57
+ // ── Daily Health ───────────────────────────────────────────────────
58
+ {
59
+ name: "get-daily-summary",
60
+ run: ({ client, displayName, today }) => client.get(`usersummary-service/usersummary/daily/${displayName}`, {
61
+ calendarDate: today,
62
+ }),
63
+ },
64
+ {
65
+ name: "get-daily-heart-rate",
66
+ run: ({ client, today }) => client.get("wellness-service/wellness/dailyHeartRate", { date: today }),
67
+ },
68
+ {
69
+ name: "get-daily-stress",
70
+ run: ({ client, today }) => client.get(`wellness-service/wellness/dailyStress/${today}`),
71
+ },
72
+ {
73
+ name: "get-daily-summary-chart",
74
+ run: ({ client, today }) => client.get("wellness-service/wellness/dailySummaryChart/", {
75
+ date: today,
76
+ }),
77
+ },
78
+ {
79
+ name: "get-daily-intensity-minutes",
80
+ run: ({ client, today }) => client.get(`wellness-service/wellness/daily/im/${today}`),
81
+ },
82
+ {
83
+ name: "get-daily-movement",
84
+ run: ({ client, today }) => client.get("wellness-service/wellness/dailyMovement", {
85
+ calendarDate: today,
86
+ }),
87
+ },
88
+ {
89
+ name: "get-daily-respiration",
90
+ run: ({ client, today }) => client.get(`wellness-service/wellness/daily/respiration/${today}`),
91
+ },
92
+ // ── Sleep, Body Battery, HRV ───────────────────────────────────────
93
+ {
94
+ name: "get-sleep",
95
+ run: ({ client, today }) => client.get("sleep-service/sleep/dailySleepData", {
96
+ date: today,
97
+ nonSleepBufferMinutes: 60,
98
+ }),
99
+ },
100
+ {
101
+ name: "get-body-battery",
102
+ run: ({ client }) => client.get("wellness-service/wellness/bodyBattery/messagingToday"),
103
+ },
104
+ {
105
+ name: "get-hrv",
106
+ run: ({ client, today }) => client.get(`hrv-service/hrv/${today}`),
107
+ },
108
+ // ── Weight ─────────────────────────────────────────────────────────
109
+ {
110
+ name: "get-weight",
111
+ run: ({ client, today, thirtyDaysAgo }) => client.get(`weight-service/weight/range/${thirtyDaysAgo}/${today}`, {
112
+ includeAll: "true",
113
+ }),
114
+ },
115
+ // ── Personal Records ───────────────────────────────────────────────
116
+ {
117
+ name: "get-personal-records",
118
+ run: ({ client, displayName }) => client.get(`personalrecord-service/personalrecord/prs/${displayName}`, {
119
+ includeHistory: "true",
120
+ }),
121
+ },
122
+ // ── Fitness Stats / Reports ────────────────────────────────────────
123
+ {
124
+ name: "get-fitness-stats",
125
+ run: ({ client, today, thirtyDaysAgo }) => client.get("fitnessstats-service/activity", {
126
+ aggregation: "daily",
127
+ startDate: thirtyDaysAgo,
128
+ endDate: today,
129
+ groupByActivityType: "true",
130
+ standardizedUnits: "true",
131
+ groupByParentActivityType: "false",
132
+ userFirstDay: "sunday",
133
+ metric: "duration",
134
+ }),
135
+ },
136
+ {
137
+ name: "get-vo2max",
138
+ run: ({ client, today }) => client.get(`metrics-service/metrics/maxmet/latest/${today}`),
139
+ },
140
+ {
141
+ name: "get-hr-zones-config",
142
+ run: ({ client }) => client.get("biometric-service/heartRateZones/"),
143
+ },
144
+ ];
145
+ async function main() {
146
+ console.log("garmin-connect-mcp integration tests\n");
147
+ const client = new GarminClient();
148
+ const today = new Date().toISOString().slice(0, 10);
149
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000)
150
+ .toISOString()
151
+ .slice(0, 10);
152
+ // Bootstrap: resolve displayName
153
+ console.log("Bootstrapping...");
154
+ const settings = (await client.get("userprofile-service/userprofile/settings"));
155
+ const displayName = settings.displayName;
156
+ if (!displayName) {
157
+ console.error("FATAL: Could not resolve displayName from settings");
158
+ console.error("Response:", JSON.stringify(settings).slice(0, 500));
159
+ await client.close();
160
+ process.exit(1);
161
+ }
162
+ console.log(` displayName: ${displayName}`);
163
+ // Bootstrap: get a recent activityId
164
+ const activities = (await client.get("activitylist-service/activities/search/activities", { limit: 1, start: 0 }));
165
+ const activityId = String(activities?.[0]?.activityId ?? "");
166
+ if (!activityId) {
167
+ console.error("FATAL: No activities found");
168
+ await client.close();
169
+ process.exit(1);
170
+ }
171
+ console.log(` activityId: ${activityId}`);
172
+ console.log(` date range: ${thirtyDaysAgo} → ${today}\n`);
173
+ const ctx = {
174
+ client,
175
+ displayName,
176
+ activityId,
177
+ today,
178
+ thirtyDaysAgo,
179
+ };
180
+ let passed = 0;
181
+ let failed = 0;
182
+ for (const test of tests) {
183
+ const start = Date.now();
184
+ try {
185
+ const result = await test.run(ctx);
186
+ if (result === undefined) {
187
+ throw new Error("undefined response");
188
+ }
189
+ const ms = Date.now() - start;
190
+ const noData = result && typeof result === "object" && "noData" in result;
191
+ console.log(` PASS ${test.name} (${ms}ms)${noData ? " [no data for date]" : ""}`);
192
+ passed++;
193
+ }
194
+ catch (e) {
195
+ const ms = Date.now() - start;
196
+ const msg = e instanceof Error ? e.message : String(e);
197
+ // Truncate long error messages
198
+ const short = msg.length > 120 ? msg.slice(0, 120) + "..." : msg;
199
+ console.log(` FAIL ${test.name} (${ms}ms) — ${short}`);
200
+ failed++;
201
+ }
202
+ }
203
+ console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total`);
204
+ await client.close();
205
+ process.exit(failed > 0 ? 1 : 0);
206
+ }
207
+ main().catch((err) => {
208
+ console.error("Fatal:", err);
209
+ process.exit(1);
210
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,442 @@
1
+ import { z } from "zod";
2
+ import { writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getSharedClient, sessionExists, getSessionFile, } from "./garmin-client.js";
5
+ function jsonResult(data) {
6
+ return {
7
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8
+ };
9
+ }
10
+ function textResult(text) {
11
+ return { content: [{ type: "text", text }] };
12
+ }
13
+ function errorResult(msg) {
14
+ return { content: [{ type: "text", text: msg }], isError: true };
15
+ }
16
+ function todayDate() {
17
+ return new Date().toISOString().slice(0, 10);
18
+ }
19
+ function getClient() {
20
+ if (!sessionExists()) {
21
+ throw new Error("No Garmin session found. The user needs to run: npx garmin-connect-mcp login");
22
+ }
23
+ return getSharedClient();
24
+ }
25
+ export function registerTools(server) {
26
+ // ── garmin-login ────────────────────────────────────────────────────
27
+ server.tool("garmin-login", "Returns step-by-step instructions for authenticating with Garmin Connect. Requires the Playwright MCP server to be installed. After following these steps, ALWAYS call the check-session tool to verify the login worked.", {}, async () => {
28
+ const sessionFile = getSessionFile();
29
+ return textResult(`# Garmin Connect Login
30
+
31
+ To authenticate, you need the Playwright MCP server installed (\`@playwright/mcp\`).
32
+
33
+ ## Steps (execute these in order):
34
+
35
+ 1. **Open Garmin Connect** using the Playwright MCP browser_navigate tool:
36
+ \`\`\`
37
+ browser_navigate → https://connect.garmin.com/app/activities
38
+ \`\`\`
39
+
40
+ 2. **Tell the user** to log in to Garmin Connect in the browser window that opened. Wait for them to confirm they are logged in and can see their activities.
41
+
42
+ 3. **Navigate to the activities page** (the login may redirect elsewhere):
43
+ \`\`\`
44
+ browser_navigate → https://connect.garmin.com/app/activities
45
+ \`\`\`
46
+
47
+ 4. **Extract the CSRF token** using browser_evaluate (NOT browser_run_code — the meta tag needs the page to be fully rendered):
48
+ \`\`\`javascript
49
+ () => {
50
+ const meta = document.querySelector('meta[name="csrf-token"]');
51
+ return meta ? meta.getAttribute('content') : 'NOT_FOUND';
52
+ }
53
+ \`\`\`
54
+ Save this value — you'll need it in step 6.
55
+
56
+ 5. **Extract cookies** using browser_run_code:
57
+ \`\`\`javascript
58
+ async (page) => {
59
+ const cookies = await page.context().cookies();
60
+ const garminCookies = cookies
61
+ .filter(c => c.domain && c.domain.includes('garmin'))
62
+ .map(c => ({ name: c.name, value: c.value, domain: c.domain }));
63
+ return JSON.stringify(garminCookies);
64
+ }
65
+ \`\`\`
66
+
67
+ 6. **Write the session file** to: ${sessionFile}
68
+ - Create the directory \`~/.garmin-connect-mcp/\` if it doesn't exist (mkdir -p)
69
+ - Combine the CSRF token from step 4 and cookies from step 5 into: \`{ "csrf_token": "<from step 4>", "cookies": <from step 5> }\`
70
+ - Write this JSON to the session file
71
+
72
+ 7. **IMPORTANT: Call the \`check-session\` tool** to verify the login worked.
73
+
74
+ ## Notes
75
+ - Session cookies expire after a few hours — re-run this flow when they do.
76
+ - The Playwright browser must stay open during steps 4-5 (don't close it before extracting).
77
+ `);
78
+ });
79
+ // ── check-session ──────────────────────────────────────────────────
80
+ server.tool("check-session", "Check if the saved Garmin Connect session is still valid. MUST be called after garmin-login to verify authentication worked.", {}, async () => {
81
+ if (!sessionExists()) {
82
+ return errorResult("No session file found. Call the garmin-login tool for instructions.");
83
+ }
84
+ try {
85
+ const client = getClient();
86
+ const profile = await client.get("userprofile-service/userprofile/user-settings/");
87
+ return jsonResult({ status: "ok", profile });
88
+ }
89
+ catch (e) {
90
+ const msg = e instanceof Error ? e.message : String(e);
91
+ return errorResult(`Session invalid or expired: ${msg}\nCall the garmin-login tool to re-authenticate.`);
92
+ }
93
+ });
94
+ // ── list-activities ────────────────────────────────────────────────
95
+ server.tool("list-activities", "List your Garmin Connect activities with pagination", {
96
+ limit: z
97
+ .number()
98
+ .default(20)
99
+ .describe("Max activities to return (1-100)"),
100
+ start: z.number().default(0).describe("Pagination offset"),
101
+ }, async ({ limit, start }) => {
102
+ const client = getClient();
103
+ const data = await client.get("activitylist-service/activities/search/activities", { limit, start });
104
+ return jsonResult(data);
105
+ });
106
+ // ── get-activity ───────────────────────────────────────────────────
107
+ server.tool("get-activity", "Get full activity summary (name, type, distance, duration, HR, calories, etc.)", {
108
+ activityId: z.string().describe("The activity ID"),
109
+ }, async ({ activityId }) => {
110
+ const client = getClient();
111
+ const data = await client.get(`activity-service/activity/${activityId}`);
112
+ return jsonResult(data);
113
+ });
114
+ // ── get-activity-details ───────────────────────────────────────────
115
+ server.tool("get-activity-details", "Get time-series metrics for an activity (HR, cadence, elevation, pace over time)", {
116
+ activityId: z.string().describe("The activity ID"),
117
+ maxChartSize: z
118
+ .number()
119
+ .default(10000)
120
+ .describe("Max data points to return"),
121
+ }, async ({ activityId, maxChartSize }) => {
122
+ const client = getClient();
123
+ const data = await client.get(`activity-service/activity/${activityId}/details`, { maxChartSize, maxPolylineSize: 0, maxHeatMapSize: 2000 });
124
+ return jsonResult(data);
125
+ });
126
+ // ── get-activity-splits ────────────────────────────────────────────
127
+ server.tool("get-activity-splits", "Get lap/split data for an activity", {
128
+ activityId: z.string().describe("The activity ID"),
129
+ }, async ({ activityId }) => {
130
+ const client = getClient();
131
+ const data = await client.get(`activity-service/activity/${activityId}/splits`);
132
+ return jsonResult(data);
133
+ });
134
+ // ── get-activity-hr-zones ──────────────────────────────────────────
135
+ server.tool("get-activity-hr-zones", "Get heart rate time-in-zone breakdown for an activity", {
136
+ activityId: z.string().describe("The activity ID"),
137
+ }, async ({ activityId }) => {
138
+ const client = getClient();
139
+ const data = await client.get(`activity-service/activity/${activityId}/hrTimeInZones`);
140
+ return jsonResult(data);
141
+ });
142
+ // ── get-activity-polyline ──────────────────────────────────────────
143
+ server.tool("get-activity-polyline", "Get full-resolution GPS track/polyline for an activity", {
144
+ activityId: z.string().describe("The activity ID"),
145
+ }, async ({ activityId }) => {
146
+ const client = getClient();
147
+ const data = await client.get(`activity-service/activity/${activityId}/polyline/full-resolution/`);
148
+ return jsonResult(data);
149
+ });
150
+ // ── get-activity-weather ───────────────────────────────────────────
151
+ server.tool("get-activity-weather", "Get weather conditions during an activity", {
152
+ activityId: z.string().describe("The activity ID"),
153
+ }, async ({ activityId }) => {
154
+ const client = getClient();
155
+ const data = await client.get(`activity-service/activity/${activityId}/weather`);
156
+ return jsonResult(data);
157
+ });
158
+ // ── get-user-profile ───────────────────────────────────────────────
159
+ server.tool("get-user-profile", "Get your Garmin Connect user profile and settings", {}, async () => {
160
+ const client = getClient();
161
+ const data = await client.get("userprofile-service/userprofile/user-settings/");
162
+ return jsonResult(data);
163
+ });
164
+ // ── download-fit ───────────────────────────────────────────────────
165
+ server.tool("download-fit", "Download the original FIT file for an activity. Returns the file path.", {
166
+ activityId: z.string().describe("The activity ID"),
167
+ outputDir: z
168
+ .string()
169
+ .default("./fit_files")
170
+ .describe("Directory to save the FIT file"),
171
+ }, async ({ activityId, outputDir }) => {
172
+ const client = getClient();
173
+ const zipBytes = await client.getBytes(`download-service/files/activity/${activityId}`);
174
+ mkdirSync(outputDir, { recursive: true });
175
+ // The response is a zip containing the .fit file
176
+ // Use a minimal zip extraction (ZIP local file header parsing)
177
+ const fitFile = extractFitFromZip(zipBytes, activityId);
178
+ if (fitFile) {
179
+ const outPath = join(outputDir, fitFile.name);
180
+ writeFileSync(outPath, fitFile.data);
181
+ return textResult(`Downloaded FIT file: ${outPath} (${fitFile.data.length} bytes)`);
182
+ }
183
+ // Fallback: save the raw zip
184
+ const zipPath = join(outputDir, `${activityId}.zip`);
185
+ writeFileSync(zipPath, zipBytes);
186
+ return textResult(`No .fit file found in archive. Saved raw zip: ${zipPath}`);
187
+ });
188
+ // ══════════════════════════════════════════════════════════════════
189
+ // Daily Health
190
+ // ══════════════════════════════════════════════════════════════════
191
+ server.tool("get-daily-summary", "Get daily summary: steps, calories, distance, intensity minutes, floors, etc.", {
192
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
193
+ }, async ({ date }) => {
194
+ const client = getClient();
195
+ const d = date ?? todayDate();
196
+ const displayName = await client.getDisplayName();
197
+ const data = await client.get(`usersummary-service/usersummary/daily/${displayName}`, { calendarDate: d });
198
+ return jsonResult(data);
199
+ });
200
+ server.tool("get-daily-heart-rate", "Get heart rate data throughout the day (resting HR, HR timeline)", {
201
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
202
+ }, async ({ date }) => {
203
+ const client = getClient();
204
+ const d = date ?? todayDate();
205
+ const data = await client.get("wellness-service/wellness/dailyHeartRate", { date: d });
206
+ return jsonResult(data);
207
+ });
208
+ server.tool("get-daily-stress", "Get stress level data throughout the day", {
209
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
210
+ }, async ({ date }) => {
211
+ const client = getClient();
212
+ const d = date ?? todayDate();
213
+ const data = await client.get(`wellness-service/wellness/dailyStress/${d}`);
214
+ return jsonResult(data);
215
+ });
216
+ server.tool("get-daily-summary-chart", "Get daily wellness summary chart data (combined health metrics)", {
217
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
218
+ }, async ({ date }) => {
219
+ const client = getClient();
220
+ const d = date ?? todayDate();
221
+ const data = await client.get("wellness-service/wellness/dailySummaryChart/", { date: d });
222
+ return jsonResult(data);
223
+ });
224
+ server.tool("get-daily-intensity-minutes", "Get intensity minutes earned for a date", {
225
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
226
+ }, async ({ date }) => {
227
+ const client = getClient();
228
+ const d = date ?? todayDate();
229
+ const data = await client.get(`wellness-service/wellness/daily/im/${d}`);
230
+ return jsonResult(data);
231
+ });
232
+ server.tool("get-daily-movement", "Get daily movement/activity data", {
233
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
234
+ }, async ({ date }) => {
235
+ const client = getClient();
236
+ const d = date ?? todayDate();
237
+ const data = await client.get("wellness-service/wellness/dailyMovement", {
238
+ calendarDate: d,
239
+ });
240
+ return jsonResult(data);
241
+ });
242
+ server.tool("get-daily-respiration", "Get respiration rate data for a date", {
243
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
244
+ }, async ({ date }) => {
245
+ const client = getClient();
246
+ const d = date ?? todayDate();
247
+ const data = await client.get(`wellness-service/wellness/daily/respiration/${d}`);
248
+ return jsonResult(data);
249
+ });
250
+ // ══════════════════════════════════════════════════════════════════
251
+ // Sleep, Body Battery, HRV
252
+ // ══════════════════════════════════════════════════════════════════
253
+ server.tool("get-sleep", "Get sleep data: score, duration, stages, SpO2, HRV during sleep", {
254
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
255
+ }, async ({ date }) => {
256
+ const client = getClient();
257
+ const d = date ?? todayDate();
258
+ const data = await client.get("sleep-service/sleep/dailySleepData", {
259
+ date: d,
260
+ nonSleepBufferMinutes: 60,
261
+ });
262
+ return jsonResult(data);
263
+ });
264
+ server.tool("get-body-battery", "Get today's body battery charged/drained values", {}, async () => {
265
+ const client = getClient();
266
+ const data = await client.get("wellness-service/wellness/bodyBattery/messagingToday");
267
+ return jsonResult(data);
268
+ });
269
+ server.tool("get-hrv", "Get heart rate variability (HRV) data for a date", {
270
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
271
+ }, async ({ date }) => {
272
+ const client = getClient();
273
+ const d = date ?? todayDate();
274
+ const data = await client.get(`hrv-service/hrv/${d}`);
275
+ return jsonResult(data);
276
+ });
277
+ // ══════════════════════════════════════════════════════════════════
278
+ // Weight
279
+ // ══════════════════════════════════════════════════════════════════
280
+ server.tool("get-weight", "Get weight measurements over a date range", {
281
+ startDate: z.string().describe("Start date YYYY-MM-DD"),
282
+ endDate: z.string().describe("End date YYYY-MM-DD"),
283
+ }, async ({ startDate, endDate }) => {
284
+ const client = getClient();
285
+ const data = await client.get(`weight-service/weight/range/${startDate}/${endDate}`, { includeAll: "true" });
286
+ return jsonResult(data);
287
+ });
288
+ // ══════════════════════════════════════════════════════════════════
289
+ // Personal Records
290
+ // ══════════════════════════════════════════════════════════════════
291
+ server.tool("get-personal-records", "Get all personal records with history (fastest mile, longest run, etc.)", {}, async () => {
292
+ const client = getClient();
293
+ const displayName = await client.getDisplayName();
294
+ const data = await client.get(`personalrecord-service/personalrecord/prs/${displayName}`, { includeHistory: "true" });
295
+ return jsonResult(data);
296
+ });
297
+ // ══════════════════════════════════════════════════════════════════
298
+ // Fitness Stats / Reports
299
+ // ══════════════════════════════════════════════════════════════════
300
+ server.tool("get-fitness-stats", "Get aggregated fitness stats by activity type over a date range", {
301
+ startDate: z.string().describe("Start date YYYY-MM-DD"),
302
+ endDate: z.string().describe("End date YYYY-MM-DD"),
303
+ aggregation: z
304
+ .string()
305
+ .default("daily")
306
+ .describe("Aggregation period: daily, weekly, monthly"),
307
+ metric: z
308
+ .string()
309
+ .default("duration")
310
+ .describe("Metric: duration, distance, calories"),
311
+ }, async ({ startDate, endDate, aggregation, metric }) => {
312
+ const client = getClient();
313
+ const data = await client.get("fitnessstats-service/activity", {
314
+ aggregation,
315
+ startDate,
316
+ endDate,
317
+ groupByActivityType: "true",
318
+ standardizedUnits: "true",
319
+ groupByParentActivityType: "false",
320
+ userFirstDay: "sunday",
321
+ metric,
322
+ });
323
+ return jsonResult(data);
324
+ });
325
+ server.tool("get-vo2max", "Get latest VO2 Max / fitness level estimate", {
326
+ date: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
327
+ }, async ({ date }) => {
328
+ const client = getClient();
329
+ const d = date ?? todayDate();
330
+ const data = await client.get(`metrics-service/metrics/maxmet/latest/${d}`);
331
+ return jsonResult(data);
332
+ });
333
+ server.tool("get-hr-zones-config", "Get your configured heart rate zone boundaries", {}, async () => {
334
+ const client = getClient();
335
+ const data = await client.get("biometric-service/heartRateZones/");
336
+ return jsonResult(data);
337
+ });
338
+ // ══════════════════════════════════════════════════════════════════
339
+ // Testing
340
+ // ══════════════════════════════════════════════════════════════════
341
+ server.tool("run-tests", "Returns a test plan for verifying all garmin-connect-mcp tools work. Call each tool listed and report results.", {}, async () => {
342
+ return textResult(`# garmin-connect-mcp Test Plan
343
+
344
+ Execute each tool below and verify it returns valid data (not an error).
345
+ Report results as a table: tool name, status (PASS/FAIL), notes.
346
+
347
+ ## Prerequisites
348
+ 1. Call check-session first. If it fails, call garmin-login to authenticate.
349
+
350
+ ## Tests (run in order)
351
+
352
+ ### Session
353
+ - check-session -> should return { status: "ok", profile: { ... } }
354
+
355
+ ### Activities
356
+ - list-activities (limit: 3) -> should return array of 3 activities
357
+ - get-activity (use activityId from above) -> should return activity object with summaryDTO
358
+ - get-activity-details (same ID) -> should return metricDescriptors + metrics
359
+ - get-activity-splits (same ID) -> should return lapDTOs array
360
+ - get-activity-hr-zones (same ID) -> should return array of 5 zones with secsInZone
361
+ - get-activity-polyline (same ID) -> should return polyline data (may fail for indoor activities)
362
+ - get-activity-weather (same ID) -> should return weather data (may fail for indoor activities)
363
+
364
+ ### Daily Health (use today's date or omit for default)
365
+ - get-daily-summary -> should return steps, calories, distance fields
366
+ - get-daily-heart-rate -> should return heartRateValues array
367
+ - get-daily-stress -> should return stressValuesArray
368
+ - get-daily-summary-chart -> should return chart data object
369
+ - get-daily-intensity-minutes -> should return intensity minutes data
370
+ - get-daily-movement -> should return movement data
371
+ - get-daily-respiration -> should return respiration data
372
+
373
+ ### Sleep / Body Battery / HRV
374
+ - get-sleep -> should return sleep score, duration, sleep stages
375
+ - get-body-battery -> should return charged/drained values
376
+ - get-hrv -> should return HRV data (may return { noData: true } if no overnight data yet)
377
+
378
+ ### Weight / Records / Fitness
379
+ - get-weight (startDate: 30 days ago, endDate: today) -> should return weight data (may be empty array)
380
+ - get-personal-records -> should return personal records with history
381
+ - get-fitness-stats (startDate: 30 days ago, endDate: today) -> should return activity stats by type
382
+ - get-vo2max -> should return VO2 max estimate
383
+ - get-hr-zones-config -> should return HR zone boundaries
384
+ - get-user-profile -> should return user settings with userData
385
+
386
+ ### Download
387
+ - download-fit (use activityId from list, outputDir: /tmp/garmin-test) -> should save .fit file and return path
388
+
389
+ ## Expected Acceptable Failures
390
+ - get-activity-polyline / get-activity-weather may fail for indoor activities (no GPS/weather data)
391
+ - get-hrv may return { noData: true } for today if overnight data hasn't synced yet
392
+ - get-weight may return empty array if no weight entries recorded
393
+
394
+ ## Report
395
+ Present results as a markdown table: | Tool | Status | Notes |
396
+ Count total passed vs failed at the end.`);
397
+ });
398
+ }
399
+ /**
400
+ * Minimal zip extraction — finds and extracts the first .fit file from a zip buffer.
401
+ * Avoids needing a zip library dependency.
402
+ */
403
+ function extractFitFromZip(buf, activityId) {
404
+ // ZIP local file header signature: PK\x03\x04
405
+ let offset = 0;
406
+ while (offset < buf.length - 30) {
407
+ if (buf[offset] === 0x50 &&
408
+ buf[offset + 1] === 0x4b &&
409
+ buf[offset + 2] === 0x03 &&
410
+ buf[offset + 3] === 0x04) {
411
+ const compressionMethod = buf.readUInt16LE(offset + 8);
412
+ const compressedSize = buf.readUInt32LE(offset + 18);
413
+ const uncompressedSize = buf.readUInt32LE(offset + 22);
414
+ const nameLength = buf.readUInt16LE(offset + 26);
415
+ const extraLength = buf.readUInt16LE(offset + 28);
416
+ const name = buf.toString("utf-8", offset + 30, offset + 30 + nameLength);
417
+ const dataStart = offset + 30 + nameLength + extraLength;
418
+ if (name.endsWith(".fit") && compressionMethod === 0) {
419
+ // Stored (no compression) — just slice the data
420
+ const data = buf.subarray(dataStart, dataStart + uncompressedSize);
421
+ return { name: `${activityId}.fit`, data: Buffer.from(data) };
422
+ }
423
+ if (name.endsWith(".fit") && compressionMethod === 8) {
424
+ // Deflate compressed — use Node's zlib
425
+ const { inflateRawSync } = await_import_zlib();
426
+ const compressed = buf.subarray(dataStart, dataStart + compressedSize);
427
+ const data = inflateRawSync(compressed);
428
+ return { name: `${activityId}.fit`, data };
429
+ }
430
+ // Skip to next file header
431
+ offset = dataStart + compressedSize;
432
+ }
433
+ else {
434
+ offset++;
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+ function await_import_zlib() {
440
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
441
+ return require("node:zlib");
442
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@etweisberg/garmin-connect-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Garmin Connect — access activities, metrics, and FIT files via Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "garmin-connect-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/etweisberg/garmin-connect-mcp.git"
21
+ },
22
+ "homepage": "https://github.com/etweisberg/garmin-connect-mcp#readme",
23
+ "scripts": {
24
+ "build": "tsc && chmod 755 dist/index.js",
25
+ "dev": "tsx src/index.ts",
26
+ "test": "tsx src/test.ts",
27
+ "lint": "eslint .",
28
+ "format": "prettier --write src/",
29
+ "format:check": "prettier --check src/",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.28.0",
34
+ "playwright": "^1.52.0",
35
+ "zod": "^3.25.0"
36
+ },
37
+ "devDependencies": {
38
+ "@eslint/js": "^9.0.0",
39
+ "@types/node": "^22.0.0",
40
+ "eslint": "^9.0.0",
41
+ "eslint-config-prettier": "^10.0.0",
42
+ "prettier": "^3.0.0",
43
+ "tsx": "^4.0.0",
44
+ "typescript": "^5.7.0",
45
+ "typescript-eslint": "^8.0.0"
46
+ },
47
+ "keywords": [
48
+ "garmin",
49
+ "garmin-connect",
50
+ "mcp",
51
+ "model-context-protocol",
52
+ "fit",
53
+ "fitness",
54
+ "claude"
55
+ ],
56
+ "license": "AGPL-3.0"
57
+ }