garmin-mcp
by Danielsuri
README.md
# garmin-mcp
[](https://github.com/danielsuri/garmin-mcp/actions/workflows/ci.yml)
[](pyproject.toml)
[](LICENSE)
A local, single-user, read-only [MCP](https://modelcontextprotocol.io) server
that gives Claude Code access to your own Garmin health and training data. It
exposes six composite tools — health snapshot, training status, recent runs,
run detail, body metrics and training analysis — so Claude can ground recovery
advice and workout recommendations in your actual sleep, HRV, training load,
and run history instead of guessing. Everything Garmin returns is cached
day-by-day in a local SQLite database, so repeated questions don't repeatedly
hit Garmin's servers. A local dashboard renders the result.

*Synthetic demo data, not real health data* — generated from a fabricated
8-week training block by `scripts/make_demo_screenshot.py`, which is
committed and runnable end to end:
```bash
pip install -e '.[dev]'
playwright install chromium
python scripts/make_demo_screenshot.py # writes docs/dashboard-demo.png
```
Every dependent figure in that data (efficiency factor, HR-zone shares,
weekly/block rollups) is computed from a handful of free variables by this
repo's own analysis code, not typed in independently — see
`scripts/demo_payloads.py` and `tests/test_demo_payloads.py`. See
`tests/js/fixtures/` for the (separate, simpler) fixtures the browser test
suite itself uses.
## Requirements
- Python 3.12 or newer (this machine runs 3.14)
- A Garmin Connect account
- Optionally, the `claude` CLI (to auto-register the MCP server — `install.sh`
looks for it on `PATH`, then `~/.local/bin`, `/opt/homebrew/bin`, and
`/usr/local/bin`)
## Install
```bash
git clone https://github.com/danielsuri/garmin-mcp.git
cd garmin-mcp
./install.sh
```
Then run the one-time login (details below):
```bash
.venv/bin/garmin-mcp login you@example.com
```
`install.sh` creates `.venv`, installs the package into it, finds the
`claude` CLI and registers the server at user scope if found, and symlinks
the `garmin-insights` skill into `~/.claude/skills/`. It's idempotent — safe
to re-run any time; it never clobbers an existing venv, registration, or
skill symlink it doesn't own. Pass `--dev` to also install the test extras
(pytest, playwright — skip this if you're only going to *use* the server),
`--dry-run` to preview every action without doing it, or `--help` for the
full rundown.
The login step is deliberately separate and manual: Garmin requires an
interactive login the first time (email, password, and an MFA code if you
have two-factor enabled), and it **must run in a real terminal** — it reads
your password with `getpass`, which needs an actual TTY and fails with
`EOFError` under a non-interactive shell or piped subprocess. `install.sh`
will offer to run it for you if your shell is interactive, and skips it
cleanly otherwise so a scripted/CI install never hits an unannounced password
prompt.
On success, tokens are written to `~/.garminconnect`. They're good for roughly
a year; after that (or if Garmin invalidates them) re-run `login`.
## Check the install
```bash
.venv/bin/garmin-mcp doctor
```
`doctor` checks three things: that the stored tokens are accepted, that a live
call to Garmin actually succeeds, and that the cache directory
(`~/.garmin-mcp/`) is writable. Healthy output looks like:
```
auth: OK (tokenstore accepted)
live call: OK (userProfileId=12345678)
cache path: OK (/Users/you/.garmin-mcp/cache.db)
```
Any `FAIL` line points at what to fix — usually re-running `login` if auth
fails, or a permissions problem if the cache path fails.
## Register with Claude Code
`install.sh` does this automatically when it can find the `claude` CLI. To do
it by hand instead (or if it couldn't find `claude`):
```bash
claude mcp add -s user garmin -- <your/install/dir>/garmin-mcp/.venv/bin/garmin-mcp serve
```
This runs the server over stdio. Once registered, a fresh Claude Code session
will have the six tools below available — MCP servers are loaded at startup,
so the session you register from won't see them.
`-s user` matters: without it `claude mcp add` defaults to *local* scope, and
the tools appear only when you're working inside this repo. User scope makes
them available in every project, which is what you want for a personal health
server.
Note the registration is an **absolute path** into this repo's venv, so moving
or deleting the repo breaks it (re-run `install.sh`, or the command above,
after moving it). Tokens (`~/.garminconnect`) and the cache
(`~/.garmin-mcp/cache.db`) live outside the repo and are unaffected by scope.
## The six tools
- **`get_health_snapshot(days=7)`** — sleep (total/deep/light/REM/awake
minutes, sleep score), HRV, body battery (the day's true high and low), resting
heart rate, and stress over the window, plus rolling baselines. Use this to
judge how recovered you are.
- **`get_training_status(weeks=4)`** — acute and chronic training load, the
acute:chronic load ratio, VO2max, and training readiness score per day. Use
this to judge whether training load is ramping safely.
- **`get_recent_runs(n=10)`** — your last `n` runs with pace (average,
moving, grade-adjusted, best), heart-rate zones, training effect and
training load, VO2max, fastest splits, PR flag, calories, body-battery
cost and elevation, plus weekly mileage rollups and race-time predictions.
Use this to spot trends and prescribe a specific run.
- **`get_run_detail(activity_id=None)`** — one run in full: everything
`get_recent_runs` reports for that run, plus running dynamics (ground
contact time and balance, vertical oscillation and ratio, stride length),
power (average/normalized/max watts and power zones), respiration
(average/max/min breaths per minute), min/max temperature, lap count,
step count, activity name, and run/walk/idle time detection
(`run_walk`: seconds spent running vs. walking vs. stopped, from Garmin's
typed splits). Omit `activity_id` for the most recent run found in the
last 56 days. Use this to deep-dive a specific run. **Costs one extra
Garmin API call** beyond `get_recent_runs` (typed splits, for run/walk
detection) — each of the other fields comes from the same activity record
`get_recent_runs` already fetches.
- **`get_body_metrics(days=30)`** — weight, body fat, steps, calories, floors
climbed, and intensity minutes, with simple weight and step trends.
- **`get_training_analysis(weeks=8)`** — the marathon-block view: within-run
form indicators (efficiency factor, aerobic decoupling, ground-contact-time
drift, duty factor, split pattern) for every run in the window, plus
across-block trends (weekly mileage, ramp rate with the base it was computed
from, HR-zone polarization, efficiency and decoupling trend, recovery
coupling, VO2max trend). It computes; it does not advise. **Costs two extra
Garmin API calls per run it hasn't seen before** (laps and typed splits),
both cached permanently. This is what the dashboard's split board and
eight-week panel are drawn from.
Arguments are clamped to a range the server can actually serve — `days` to
1–365, `weeks` to 1–52, `n` to 1–100, `activity_id` to a non-negative
64-bit value — so an out-of-range value narrows or widens to the nearest
bound (or, for an `activity_id` that matches nothing, yields a clean
all-null result) rather than failing.
Every tool returns a `partial: true` flag when it couldn't be certain all of
its data is fresh or complete — see Troubleshooting below.
## The dashboard
```bash
.venv/bin/garmin-mcp dashboard # opens http://127.0.0.1:8765/
.venv/bin/garmin-mcp dashboard --port 9000 --no-open
```
A single page that answers "do I run today, and what": the verdict and
prescription in large type at the top, then the **split board** — every run in
the last eight weeks as a bar whose *width* is its distance and whose *colour*
is its effort, cold (`#5FD0E0`) for a Z1–2 easy run through to warm
(`#FFB454`) for a Z4–5 session. Hover or tab to a bar for its date, distance,
pace, efficiency factor and decoupling. Below that, the eight-week block
numbers and the focus notes.
It binds **127.0.0.1 only** and has no authentication, because it serves your
complete health record to anything that can reach it. Don't put it behind a
tunnel or change the bind host.
Three read-only JSON endpoints back the page, and they're useful on their own:
| Endpoint | Contents |
|---|---|
| `/api/today` | `get_health_snapshot` + `get_training_status` |
| `/api/runs` | `get_recent_runs` + `get_training_analysis` |
| `/api/insights` | the insights file below, or `null` |
The page is deliberately blunt about the limits of its own data: a missing
value renders as a dash and never as zero, a ramp percentage is always shown
next to the mileage it was computed from (a "+947.7%" week off a 1.6 km base
is not the emergency it looks like), and a comparison the analysis layer
refused to make for want of a sample says so — "needs 8 paired nights, has 5".
### Installing the insights skill
The skill that writes the insights file is vendored in this repo at
`.claude/skills/garmin-insights/SKILL.md`, so a schema change and its skill
update land in the same diff. Claude Code loads skills from user scope
(`~/.claude/skills/`), so it needs to be reachable from there too —
`install.sh` symlinks it in for you. To do it by hand instead:
```bash
mkdir -p ~/.claude/skills
ln -s <your/install/dir>/garmin-mcp/.claude/skills/garmin-insights ~/.claude/skills/garmin-insights
```
Symlinking rather than copying keeps the repo copy the single source of
truth. `/garmin-insights` becomes available in any **new** Claude Code session after
that (skills are loaded at session start, same as MCP servers above).
### Refreshing the insights
The numbers on the page are live from your Garmin record. The *sentences* —
today's verdict, the prescription, the focus notes — come from
`~/.garmin-mcp/insights.json`, which Claude Code writes. There's no scheduler
and nothing automatic: ask for it.
1. In a Claude Code session, run **`/garmin-insights`** — or just ask
*"refresh my dashboard insights"*. The skill carries the schema plus the
rules that keep the prose honest: every figure must trace to the payload,
the engine's refusals are respected rather than talked over, and thin
samples are stated rather than hidden.
2. Claude calls `get_training_analysis`, `get_health_snapshot` and
`get_recent_runs`, then writes `~/.garmin-mcp/insights.json` in this shape:
```json
{"schema_version": 1,
"generated_at": "2026-08-08T06:00:00Z",
"today": {"verdict": "Go easy — yesterday's long run is still in your legs.",
"prescription": "35 min recovery @ 7:40–8:00 /km",
"evidence": ["HRV balanced at 44 ms", "Load ratio 1.11"]},
"focus": [{"title": "…", "detail": "…", "metric": "decoupling_pct"}],
"next_run": {"title": "…", "detail": "…"},
"week": {"title": "…", "detail": "…"}}
```
3. Reload the dashboard.
The page always shows how old that file is ("insights generated 2 h ago"), and
once it passes 24 hours it says so plainly instead of presenting yesterday's
reasoning as this morning's. If the file is missing or malformed, the page
shows an empty state explaining this workflow rather than an error — the live
numbers below it still render.
## Capturing fixtures
```bash
.venv/bin/garmin-mcp capture-fixtures
```
This calls each underlying Garmin endpoint directly and writes the raw JSON
responses to `tests/fixtures/`. It's a diagnostic tool for verifying Garmin's
actual response shapes against what the assemblers expect (see
`docs/api-field-map.md`). The files it writes contain **real health data** and
are gitignored (`tests/fixtures/*.json`) — never commit them, and don't print
their contents anywhere shareable.
## Testing
```bash
.venv/bin/pytest # everything: Python + the two front-end tiers below
node --test 'tests/js/**/*.test.js' # front-end-only, no Python/pytest needed
```
The suite has three layers:
- **Python** (`tests/*.py`, excluding the two below) — the MCP tools, cache,
fetcher, assemblers and dashboard server. No network, no browser.
- **Tier 1, JS units** (`tests/js/*.test.js`, wired in via
`tests/test_js_units.py`) — pure-function tests for
`src/garmin_mcp/dashboard/static/app.js` (formatters, date parsing, effort
calibration) run under node's built-in `node:test`. Zero dependencies:
no npm install, no `package.json`. `node --test 'tests/js/**/*.test.js'`
runs this layer standalone for a fast front-end-only loop while iterating
on `app.js` — a bare directory path (`node --test tests/js`) does not
reliably resolve on node v26, so the glob form is what both this command
and `tests/test_js_units.py` use. Skips (not fails) if `node` isn't on
`PATH`.
- **Tier 2, browser layout** (`tests/test_dashboard_browser.py`) — real
layout checks (bar widths, grid wrapping, 200% text-zoom overflow) driven
with [Playwright](https://playwright.dev) against a throwaway local server
serving the static page plus fixtures from `tests/js/fixtures/` (synthetic,
committed — no health data). Requires the `dev` extra and a one-time
browser download:
```bash
.venv/bin/pip install -e ".[dev]"
.venv/bin/playwright install chromium # one-time, ~150 MB download
```
Skips cleanly (not fails) if `playwright` isn't installed, or if it's
installed but `playwright install chromium` hasn't been run yet — both a
fresh clone and a CI box without either dependency still pass the suite,
they just see fewer tests run. This layer binds `127.0.0.1` on an
OS-assigned ephemeral port (never a fixed port, never `8765`) and never
calls Garmin — see the module docstring for why it's the one place in the
suite that opens a socket at all.
## Troubleshooting
- **Auth errors / "Garmin rejected the stored tokens"** — your tokens expired
or were revoked. Re-run `garmin-mcp login you@example.com` from a real
terminal.
- **`partial: true` in a tool's response** — Garmin rate-limited a request or
a connection failed transiently. The tool still returns whatever it has
cached; treat the numbers as possibly incomplete or stale and try again
later rather than assuming Garmin has no data.
- **Weight fields are `null`** — this is normal on any day without a weigh-in.
`weight_kg`, `body_fat_pct`, and the weight trend fields in
`get_body_metrics` only populate on days you actually stepped on a
connected scale.
## Licence
MIT — see [`LICENSE`](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing