garmin-coach
# garmin-coach
A local stdio MCP server that gives Claude Code read access to your Garmin
Connect account, plus a gated, allow-listed path for creating and scheduling
workouts. Intended as the data layer for an AI training/fat-loss coach.
## Architecture
```
Claude Code
↓ MCP (stdio, local subprocess — no network)
garmin_mcp.server 25 read-only tools (+6 when writes are enabled)
↓
garmin_mcp.client token-only auth, error normalisation
↓ python-garminconnect 0.3.11
Garmin Connect (HTTPS)
↓ device sync
Garmin Forerunner 970
```
Everything runs on your Mac. The only network traffic is normal HTTPS to
Garmin. Nothing is sent to a third party.
## Install
```bash
cd ~/Developer/garmin-coach
uv sync
```
Python is pinned to 3.12 via `.python-version`. `garminconnect` requires ≥3.12.
## Initial Garmin login — run this yourself
Credentials are only ever entered interactively, in your own terminal:
```bash
cd ~/Developer/garmin-coach
uv run python scripts/garmin_login.py
```
It prompts for your email, then your password via `getpass` (hidden, never in
shell history), and handles an MFA code if Garmin asks. On success it mints
OAuth tokens and does a small harmless read to prove the session works.
If Garmin starts rate limiting, the script stops and tells you to wait — it
does not retry in a loop, which is what gets accounts throttled harder.
## Token storage and security
- Tokens live in `~/.garmin-coach/tokens` — **outside** the project, so they
can never be swept into git.
- The directory is forced to `0700`, every file inside to `0600`.
- Your password is used exactly once, to mint tokens. It is never stored, never
written to `.env`, never in MCP config, never in the repo.
- Token contents are never logged. Logging goes to stderr only (stdout is the
MCP protocol channel).
- Override the location with `GARMIN_TOKENSTORE=/some/path`.
**Treat `~/.garmin-coach/tokens` as equivalent to your password.** Anyone with
those files has full read/write access to your Garmin account.
## Test the MCP
```bash
uv run python scripts/smoke_test.py
```
Stage 1 runs without any Garmin auth — it exercises the stdio transport, tool
discovery, the bundled exercise catalog, the workout builder, and the error
paths. Stage 2 runs the live read tools, and is skipped automatically until
you have logged in.
## Connect to Claude Code
Already registered user-scoped (available in every project):
```bash
claude mcp add --scope user garmin -- \
/opt/homebrew/bin/uv run --directory ~/Developer/garmin-coach \
python -m garmin_mcp.server
claude mcp get garmin # check status
```
`uv run --directory` resolves the project's own virtualenv, so you never need
to activate anything manually.
## Tools
**Health** — `garmin_today`, `garmin_sleep`, `garmin_hrv`, `garmin_stress`,
`garmin_body_battery`, `garmin_heart_rate`, `garmin_recovery`
**Training** — `garmin_training_readiness`, `garmin_training_status`,
`garmin_hr_zones`, `garmin_performance_metrics`, `garmin_recent_activities`,
`garmin_activities_by_date`, `garmin_activity`, `garmin_strength_sets`,
`garmin_exercise_history`
**Workout library (read-only)** — `garmin_workouts`, `garmin_workout`,
`garmin_scheduled_workouts`, `garmin_search_exercises`,
`garmin_exercise_categories`, `garmin_resolve_exercise`,
`garmin_workout_step_formats`, `garmin_devices`,
`garmin_preview_strength_workout`
All dates are ISO `YYYY-MM-DD` and default to today. Responses are normalised
and compact; **a value Garmin did not return is omitted rather than guessed**,
so "field absent" and "value is zero" stay distinguishable.
### Write tools (disabled by default)
Not registered at all unless `GARMIN_MCP_ENABLE_WRITES=1` is in the server
environment, and independently re-checked at call time:
`garmin_create_strength_workout`, `garmin_create_cardio_workout`,
`garmin_update_workout`, `garmin_schedule_workout`,
`garmin_unschedule_workout`, `garmin_push_workout_to_device`
This is an allow-list, not a passthrough. Deleting activities, gear, or
profile data is not exposed. `garmin_unschedule_workout` additionally requires
`confirm=true`.
To enable:
```bash
claude mcp remove garmin -s user
claude mcp add --scope user garmin -e GARMIN_MCP_ENABLE_WRITES=1 -- \
/opt/homebrew/bin/uv run --directory ~/Developer/garmin-coach \
python -m garmin_mcp.server
```
Use `garmin_preview_strength_workout` first — it builds and renders the exact
payload without uploading. Verify the structure before anything reaches your
calendar or watch.
## Known Garmin limitations
The unofficial API does not expose everything the watch shows. The full
matrix — what's on the **watch** vs in **Garmin Connect** vs reachable via
**python-garminconnect** — is in [`docs/forerunner-970.md`](docs/forerunner-970.md).
The ones that shape the coaching design:
- **No RIR/RPE field exists.** Garmin has nowhere to put it.
- **Garmin stores one concrete rep target per set**, not a range. A
"5–8 @ 1 RIR" prescription cannot round-trip — push the concrete target
(e.g. 6) and keep the programming rule in the coaching layer. Garmin is the
execution interface, not the source of truth for the program.
- **Per-set heart rate is not available**; HR is session-level only.
- **Auto-detected reps are often wrong** for machines/cables until corrected
in Garmin Connect.
- **Use `garmin_hr_zones`, never computed zones.** Zone 2 is a heart-rate
range from your Garmin profile, not a hard-coded pace.
## Re-authenticate or revoke
```bash
rm -rf ~/.garmin-coach/tokens # revoke locally
uv run python scripts/garmin_login.py # re-authenticate
```
Tokens auto-refresh, so this is only needed if the refresh token expires or
you revoke it. To revoke server-side, change your Garmin password — that
invalidates the tokens.
## Remove from Claude Code
```bash
claude mcp remove garmin -s user
```
That only unregisters the server. To fully remove: also delete
`~/.garmin-coach` and `~/Developer/garmin-coach`.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `No Garmin tokens found in …` | Run `scripts/garmin_login.py` |
| `Saved Garmin tokens were rejected` | Refresh token expired/revoked — log in again |
| `Garmin is rate limiting this account` | Wait 15–30 min. Do **not** retry in a loop |
| `Could not reach Garmin Connect` | Network down, or Garmin maintenance |
| Tool returns `available: false` | Garmin genuinely has no data for that date |
| MCP not detected | `claude mcp get garmin`; check `uv` path is absolute |
Set `GARMIN_MCP_LOG_LEVEL=DEBUG` for verbose stderr logging.
# Garmin-MCP
TDQS
Scored across 25 tools
Most tools map to a distinct Garmin metric or resource, and the descriptions clearly separate daily summaries, sleep, HRV, stress, recovery, readiness, and training status. A couple of composite tools overlap in content—recovery vs readiness, training status vs performance metrics—but the descriptions are specific enough to avoid serious misselection.
All tools share a clear garmin_ prefix and mostly follow a resource-oriented naming pattern. Some verbs appear inconsistently (search_exercises, resolve_exercise, preview_strength_workout) compared to noun-only names, but the overall pattern remains predictable and readable.
At 25 tools, this sits right at the heavy end of the borderline range. Each tool maps to a real Garmin endpoint, but the set includes several overlapping composite views and could likely be consolidated without losing capability.
The read side is comprehensive for health metrics and activities, but the workout workflow is incomplete: preview_strength_workout explicitly avoids uploading, and devices mentions needing IDs to push a workout, yet no tool actually pushes, saves, schedules, or edits a workout. This is a significant dead end for the stated coaching purpose.