@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 +17 -0
- package/README.md +182 -0
- package/dist/auth.js +80 -0
- package/dist/garmin-client.js +171 -0
- package/dist/index.js +28 -0
- package/dist/test.js +210 -0
- package/dist/tools.js +442 -0
- package/package.json +57 -0
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
|
+
}
|