garmin-mcp-local
# garmin-mcp-local
A personal Garmin Connect MCP server: a local SQLite cache of your Garmin
data, a resumable historical backfill (from Garmin's bulk data export and/or
the live API), and a rate-limited sync so nothing gets silently dropped or
duplicated. Exposes read-only MCP tools backed entirely by the local cache,
plus one explicit `sync` tool that talks to the live API.
Talks to Garmin two ways:
- **Garmin's official [Connect Personal API](https://developer.garmin.com/gc-developer-program/personal/)**
(when `GARMIN_API_KEY` is set) for activities, laps, daily summary, sleep,
HRV and body composition.
- **[`garminconnect`](https://github.com/cyberjunky/python-garminconnect)**
(cyberjunky, unofficial) for training readiness, training status and race
predictions, which the official API doesn't serve, and as the fallback for
everything when no API key is set.
Both serve the same Connect endpoints with the same JSON, so the same
mappers handle either source.
## Why a local cache instead of calling the API every time?
Garmin Connect's API is not designed for bulk historical queries and will
rate-limit (429) or block (403) you if you hit it too hard. This project
fetches once, caches locally in SQLite, and answers all normal queries from
that cache. A live sync is a separate, explicit step.
## Setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
# edit .env: set GARMIN_EMAIL / GARMIN_PASSWORD (only needed for the first
# login -- after that, the session token is cached under GARMIN_TOKEN_STORE
# and credentials aren't read again unless the token expires/is revoked)
# Optional but recommended: set GARMIN_API_KEY (create one at
# https://connect.garmin.com/app/settings/personalApiToken, max lifetime one
# year) and GARMIN_API_KEY_EXPIRES so the watchdog warns before it lapses.
```
With an API key, the unofficial login happens only when a category that
needs it runs. If it fails (password change, MFA, Garmin blocking the
scraper), the official-API categories still sync, the others are logged as
failed, and `garmin-mcp-sync` / `garmin-mcp-backfill` send the crash alert
after saving what they could.
Nothing in `.env` is ever committed -- `.gitignore` excludes it, the SQLite
DB file, and the token cache directory. `.env.example` only has placeholders.
## First-run backfill
### 1. Seed from Garmin's bulk data export (recommended -- avoids re-fetching years of history through the rate-limited API)
Request your data from Garmin: **Garmin Connect → Account Settings → Export
Your Data**. Garmin emails you a download link for a zip archive (despite
often being called a "CSV export" request, the archive Garmin actually
ships is JSON files organized under `DI_CONNECT/` by category -- this
project parses that directly, no CSV involved).
```bash
garmin-mcp-import-export /path/to/your-export.zip
```
This prints a report of what was imported per category, and explicitly
flags what the export *can't* populate (see [Known gaps](#known-gaps-in-the-bulk-export)
below). Re-running the import against the same (or an updated) export file
is always safe -- every table has a natural-key `UNIQUE` constraint and the
importer upserts, so nothing is ever duplicated.
### 2. Fill in anything the export didn't cover, via the live API
```bash
garmin-mcp-backfill --batch-days 30 --earliest-date 2015-01-01
```
Each invocation walks one controlled batch further back into history per
category (rather than trying to fetch everything at once) and persists a
resume cursor, so you can run it repeatedly (e.g. on a cron job, or via
the MCP `backfill_batch_now` tool) until it reaches `--earliest-date`.
## Ongoing incremental sync
```bash
garmin-mcp-sync
```
Pulls everything new since the last successful sync, for every category.
This is **never** triggered automatically by a query -- run it explicitly,
or wire it into a cron job / the MCP `sync_now` tool.
**Empty-day gap walk-back:** if the phone/watch failed to upload for a few
days, the API can return empty payloads and the forward cursor still advances.
When Connect later gets those nights, a tip-only sync would miss them.
Incremental sync therefore walks **backward from the cursor through
consecutive local gaps** (missing row or null shell) until it hits a day
with real metrics, capped at 21 days (`GAP_REFETCH_MAX_DAYS`), then
re-fetches that range through today.
**Settle window:** a day synced before the phone finished uploading it holds
partial totals and so doesn't count as a gap. Every incremental run also
re-fetches the two days before the cursor (`SETTLE_DAYS`), filled or not,
so a late upload replaces those partial numbers.
**Date-range fetches:** with an API key, sleep, HRV and body composition
are fetched in one call per 31-day chunk (Garmin's limit) instead of one
call per day, so a 30-day backfill batch is a single request. Daily health
stays one call per day: the official range endpoint reports different
values for the same day (step-only distance, slightly different min/max HR),
which would change what those columns mean. Training readiness, training
status and race predictions stay per day on `garminconnect`.
**Deleted activities:** Connect has no feed of deletions, so incremental
sync infers them. Any local API-sourced activity newer than the oldest one
on the activity pages just fetched (the latest 50 or more), but missing
from them, was deleted in Connect, for example a Peloton duplicate removed
by hand. It is deleted locally with its laps, and the `sync_log` row notes
which IDs were removed. If more than 5 disappear in one run, nothing is
deleted and the run is marked partial, since that looks like a bad API
response rather than hand-deleted duplicates. Activities imported from the
bulk export are never removed this way.
Every API call in this project goes through one rate-limited wrapper
(`garmin_mcp/garmin_client/rate_limiter.py`) that:
- enforces a minimum delay between requests (`GARMIN_MIN_REQUEST_INTERVAL_SECONDS`),
- retries 429/403 with exponential backoff + jitter, capped at `GARMIN_MAX_RETRIES`,
- persists the backoff/cooldown window to `sync_log`, so if a run gets
rate-limited and dies, the *next* run (even a fresh process) sees the
still-active cooldown and refuses to hammer the API again until it expires,
- logs a clear failure (not a silent give-up) once retries are exhausted,
- retries transient failures (5xx such as a 504 Gateway Timeout, timeouts,
connection errors) after 5, 10, 20 and 40s. These are not persisted as a
cooldown. After two calls in a row exhaust those retries, Garmin is
treated as down and later calls fail fast until one succeeds.
Retries also cover the other external actions (`garmin_mcp/retry.py`):
- the `garminconnect` login retries transient failures after 30, 60 and
120s. Bad credentials, MFA and login rate limits are never retried,
since hammering Garmin's SSO risks an account lockout;
- the scheduled `garmin-mcp-sync` and `garmin-mcp-activities-sync` re-run
the whole (idempotent) sync when a run crashed on, or ended with,
transient failures: up to `GARMIN_RUN_RETRIES` (default 2) more times,
waiting `GARMIN_RUN_RETRY_DELAY_SECONDS` (default 300, doubling each
time). The summary email and any failure alert go out once, after the
final attempt. Persistent failures (a date that always 404s, an active
rate-limit cooldown) do not trigger a re-run;
- sending mail retries a failed hand-off to `mail` twice (after 15s, 30s).
Truncation is never silent: every batch fetch records `records_expected`
vs. `records_fetched` in `sync_log`, and anything short of a full,
unambiguous fetch is marked `status='partial'` with a specific warning
(e.g. which dates failed, or which paginated batch came back short without
an explicit end-of-data signal from the API).
Check `SELECT * FROM sync_log ORDER BY started_at DESC LIMIT 20` (or the
MCP `get_sync_status` tool) any time you want to know what's actually been
fetched vs. what's stale or failed.
## Scheduling (launchd)
`sync` and `backfill` only run when invoked -- to run them automatically,
four `launchd` LaunchAgents are provided under `launchd/`:
- `local.garmin-mcp.sync` -- daily at 08:00, incremental catch-up.
- `local.garmin-mcp.backfill` -- daily at 08:30 (staggered 30 min after
sync so the two never write to the SQLite DB concurrently), 30-day
batches, scoped to the 7 wellness/health categories (`activities` is
excluded by default since a CSV-export seed already covers full activity
history -- edit the plist's `--category` flags to include it if you
didn't seed from a CSV export).
- `local.garmin-mcp.activities-sync` -- daily at 19:00, today's workouts
and daily health row (for body battery). Emails a separate summary under
subject `garmin activities YYYY-MM-DD` when `DAILY_SUMMARY_EMAIL` is set,
so the day's workouts and remaining energy are available for planning
tomorrow. Does not fetch sleep, body composition, or older activities.
- `local.garmin-mcp.watchdog` -- daily at 10:00 and 20:00, catches a
scheduled run that failed silently (see "Failure alerting" below).
`launchd` requires literal absolute paths (no `~` expansion, no env vars),
so the plists in `launchd/` are templates with a `__REPO_ROOT__`
placeholder -- install generates the real plists (with your actual path
baked in) directly into `~/Library/LaunchAgents/`, rather than symlinking
the template in place:
```bash
REPO_ROOT="$(pwd)"
for job in sync backfill activities-sync watchdog; do
sed "s|__REPO_ROOT__|$REPO_ROOT|g" \
"launchd/local.garmin-mcp.$job.plist" \
> ~/Library/LaunchAgents/"local.garmin-mcp.$job.plist"
done
launchctl load ~/Library/LaunchAgents/local.garmin-mcp.sync.plist
launchctl load ~/Library/LaunchAgents/local.garmin-mcp.backfill.plist
launchctl load ~/Library/LaunchAgents/local.garmin-mcp.activities-sync.plist
launchctl load ~/Library/LaunchAgents/local.garmin-mcp.watchdog.plist
```
Logs land in `logs/sync.log` / `logs/sync.error.log` (and the `backfill`,
`activities-sync`, and `watchdog` equivalents). Trigger a run immediately
(e.g. to test) with:
```bash
launchctl start local.garmin-mcp.sync
```
Because the generated files in `~/Library/LaunchAgents/` are a one-time
copy (not a symlink back to the template), re-run the `sed` step above and
`launchctl unload`/`load` again after editing a template in `launchd/` for
the change to take effect.
### Failure alerting
A scheduled job can fail silently two different ways: it can crash (or
never run at all -- disabled agent, sleeping laptop at 08:00), or it can
exit 0 while a per-date fetch failed internally and got logged to
`sync_log` as `status='partial'`/`'failed'`/`'rate_limited'` without
raising (see `garmin_mcp/sync/engine.py` -- one bad date is swallowed
rather than aborting the whole batch). Neither shows up unless something
reads the logs.
Set `ALERT_EMAIL_TO` in `.env` to get an email for either case, sent via
the local `mail` command (this machine already relays outbound mail
through Postfix to a real SMTP provider -- confirm `echo test | mail -s
test you@example.com` reaches your inbox before relying on this). This is
deliberately *not* a hosted heartbeat/uptime service: nothing pings out on
a normal successful day, so there's no regular outbound signal revealing
when this machine is online. The only network traffic this ever generates
is the alert itself, sent only when there's actually something to report.
- `garmin-mcp-sync` / `garmin-mcp-backfill` / `garmin-mcp-activities-sync`
email immediately on any unhandled exception (login failure, DB error,
etc.), then re-raise -- the real exit code and traceback still land in
`logs/*.error.log` exactly as before, alerting never masks it.
- `garmin-mcp-watchdog` (the third launchd job above) is what catches the
other two failure modes -- a job that never ran, or one that ran but
logged `partial`/`failed`/`rate_limited`. It reads `sync_log` for the
most recent run per category and per `run_type`, and emails one summary
if anything's stale (>27h old, one day of slack over the daily cadence)
or unsuccessful. It sends nothing when everything's healthy.
It also warns ahead of the Garmin API key's expiry when
`GARMIN_API_KEY_EXPIRES` (YYYY-MM-DD) is set: on the 30, 14, 7, 3, 2, 1
and 0 days-left marks, then on every run once the key has expired.
It also flags a stalled Garmin Connect upload. If there's still no
sleep data for last night after the sync, the phone app has usually
stopped syncing the watch in the background, even though every sync
run succeeded. (A night without the watch on triggers it too.)
Leaving `ALERT_EMAIL_TO` unset disables alerting entirely (failures are
still visible in `logs/*.error.log` and `sync_log`, just not pushed to
you). Note the watchdog job has the same "did launchd even run this"
blind spot as the jobs it's watching -- there's no way to fully close that
loop with a local-only design, which is the tradeoff for not running a
regular external heartbeat. Scheduling it twice a day (10:00 and 20:00)
mitigates but doesn't eliminate this.
### Daily summary email
Set `DAILY_SUMMARY_EMAIL` in `.env` and every `garmin-mcp-sync` run ends by
emailing a summary of the day from the local cache (sent through `mail`,
like the alerts). It's plain text meant for a bot to parse but still
readable: a `garmin_daily_summary v1` header, then `[section date=...]`
blocks of `key=value` lines, units in the key names, missing values left
out, and `status=no_data` for a section with no row. Activities are one
`activity: key=value ...` line each, with spaces in names turned into `_`.
Sleep, HRV, training readiness, training status and race predictions are
for today (the night just ended). At 08:00 today's daily health is barely
started, so it's marked `partial=true`, and yesterday's daily health and
activities follow as the finished numbers. Body composition appears only on
a day with a weigh-in.
The 19:00 `garmin-mcp-activities-sync` job uses the same
`DAILY_SUMMARY_EMAIL` recipient but sends a separate message: subject
`garmin activities YYYY-MM-DD`, body `garmin_activities_summary v1` with
that day's workout lines (or `status=no_workouts` when there were none),
then a `[body_battery date=... partial=true]` section (`latest`, `high`,
`low`, `charged`, `drained`). The job fetches that day's daily summary for
it, logged as run type `evening_body_battery` without moving the morning
sync's cursor. No sleep, other health, or prior-day sections.
```bash
garmin-mcp-daily-summary --print # show today's summary
garmin-mcp-daily-summary # email it now
garmin-mcp-daily-summary --date 2026-11-05 # another day
garmin-mcp-activities-sync --print # sync today + print activities email body
```
### macOS Documents-folder permission (if the project lives under `~/Documents`)
If your project sits under `~/Documents` (or Desktop/Downloads), macOS's
TCC privacy protection blocks `launchd`-spawned processes from reading
files there -- including the venv's own `pyvenv.cfg` -- **unless** the
Python interpreter has an explicit grant. This will surface as:
```
PermissionError: [Errno 1] Operation not permitted: '.../.venv/pyvenv.cfg'
```
This is genuinely fiddly to resolve, because System Settings' Full Disk
Access / Files and Folders "+" picker generally only lets you select `.app`
bundles, not a raw CLI Python binary -- and a background `launchd` job has
no GUI session to show an interactive consent prompt in the first place, so
it just fails silently rather than asking. What actually works:
1. Find the *real* interpreter binary your venv resolves to (pyenv/asdf
installs are usually a symlink chain):
```bash
python3 -c "import os; print(os.path.realpath('.venv/bin/python3'))"
```
2. Build a minimal `.app` bundle wrapper around it (a real bundle *is*
selectable in the TCC picker, and launching it from Finder -- with no
privileged GUI-app ancestor to silently inherit permission from -- gives
it a chance to earn its own independent, persisted grant via a genuine
consent dialog). Keep the wrapper narrowly scoped to the specific
command(s) you need, not a generic pass-through that can exec anything
it's handed.
3. Double-click that `.app` once in Finder. If a system permission dialog
appears, click Allow.
4. Verify the grant landed on the *interpreter*, not the wrapper app:
```bash
sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, auth_value FROM access WHERE client LIKE '%python3.12%';"
```
`auth_value=2` for `kTCCServiceSystemPolicyDocumentsFolder` means it
worked. The grant is tied to that interpreter binary's identity, not to
how it's invoked -- so once this is confirmed, `launchd` can call the
venv binaries **directly** (no wrapper needed going forward; the plists
in this repo already do this).
5. If `launchd`'s job also sets `WorkingDirectory` to a path under
Documents, drop it -- `/bin/bash` (or whatever shell interprets the
job) has no grant of its own, and `launchd` `chdir()`-ing it into a
protected folder before your granted interpreter ever runs breaks the
shell's own `getcwd()` at startup. This project's config
(`garmin_mcp/config.py`) resolves `.env` and all paths relative to the
repo root explicitly, independent of `cwd`, specifically so
`WorkingDirectory` isn't needed.
## MCP server (Claude Desktop / Claude Code)
```bash
garmin-mcp-server # runs on stdio
```
Add to Claude Desktop's config (`~/Library/Application Support/Claude/claude_desktop_config.json`
on macOS) under `mcpServers`:
```json
{
"mcpServers": {
"garmin": {
"command": "/absolute/path/to/garmin-mcp-local/.venv/bin/garmin-mcp-server"
}
}
}
```
No `env` overrides needed -- `garmin_mcp/config.py` resolves `.env`, the DB
path, and the token store relative to the repo root explicitly, independent
of whatever working directory Claude Desktop launches the process with.
Restart Claude Desktop to pick up the change.
Tools exposed:
| Tool | Hits the network? | Purpose |
|---|---|---|
| `list_activities` | No | Activities by date range / type |
| `get_activity_detail` | No | Full detail: laps, HR/power zones, gear |
| `get_daily_health_metrics` | No | Steps/HR/stress/body battery/SpO2/respiration by date range |
| `get_sleep` | No | Nightly sleep stages + score breakdown |
| `get_training_trends` | No | Training status/readiness/VO2max/load/race predictions |
| `get_sync_status` | No | Recent sync_log entries + resume cursors |
| `execute_sql` | No | Ad hoc read-only SQL (`SELECT`/`WITH`/`EXPLAIN`/`PRAGMA table_info` only) |
| `sync_now` | **Yes** | Explicit incremental sync |
| `backfill_batch_now` | **Yes** | One controlled backfill batch per category |
## Schema
One normalized table per data category (see `garmin_mcp/db/schema.sql` for
the authoritative, commented definition):
- **Activities**: `activities` (full per-activity detail), `activity_laps`,
`activity_hr_zones`, `activity_power_zones`, `gear`, `activity_gear`.
Laps for live-synced activities cost one extra call per activity (at most
40 per sync run, newest first). With an API key that call downloads the
activity's original FIT file and reads its lap messages; activities
without a downloadable file (third-party uploads such as Peloton) and
runs without a key use `garminconnect`'s `get_activity_splits` instead.
The two sources agree field for field except that FIT lap elevation
gain/loss is whole meters. Laps include `intensity_type`
(WARMUP/ACTIVE/RECOVERY/COOLDOWN) and `workout_step_index` for structured
watch workouts. `activities.lap_count IS NULL` on an `api` row means its
laps haven't been fetched yet.
- **Daily health**: `daily_health_metrics` (steps/calories/HR/stress/body
battery/SpO2/respiration), `daily_stress_periods` (TOTAL/AWAKE/ASLEEP
breakdown), `sleep`, `hrv_daily`, `body_composition`.
- **Training metrics**: `training_status` (VO2max, training status, load/ACWR,
endurance & hill scores), `training_readiness`, `race_predictions`.
- **Sync state**: `sync_log` (full audit trail of every import/sync/backfill
attempt, success or not) and `sync_cursor` (current resume point per
category, in both the forward/incremental and backward/backfill direction).
Every table has a stable natural key from Garmin's own IDs (`activity_id`,
`calendar_date`) with a `UNIQUE`/`PRIMARY KEY` constraint. All writes go
through one generic `upsert()` helper (`garmin_mcp/db/connection.py`), so
re-running any import or sync is always idempotent.
## Known gaps in the bulk export
Verified against a real Garmin "export all data" archive. The importer's
report flags these explicitly at the end of every `garmin-mcp-import-export`
run:
- **Nightly HRV detail** (`hrv_daily.last_night_avg`, baseline, status):
the bulk export has no dedicated nightly-HRV file. Only a weekly average
is derivable indirectly (from `TrainingReadinessDTO`), imported with
`source='csv_export_approx'`. Run a live sync (`get_hrv_data`) to backfill
nightly detail.
- **Body composition** (`body_composition`): only populated if your account
has Garmin Index smart scale data in the export. If you don't own one,
this table stays empty until/unless you add manual weigh-ins via the API.
- **Raw GPS tracks / second-by-second streams**: intentionally out of scope
for this schema (per-lap summaries and HR/power zone time-in-zone are
captured instead) -- keeps the DB small and avoids one API call per
activity during backfill. The original FIT files are included separately
in Garmin's export archive (`DI_CONNECT/DI-Connect-Uploaded-Files/`) if
you need them.
### A note on units in the bulk export
Garmin's raw export uses internal units for activity-level and per-lap
fields that don't match their own field-name suffixes: distance-like
fields are centimeters, speed-like fields are centimeters/millisecond,
duration-like fields are milliseconds. This was verified empirically
against real ride/run data during development (see comments in
`garmin_mcp/bulk_import/units.py`) and converted to meters/mps/seconds on
import. A few running-dynamics fields (vertical oscillation, ground
contact time, vertical ratio, cadence) are left as raw export values,
since their exact unit wasn't independently verifiable from the sample
data available -- cross-check against Garmin Connect's UI before relying
on them for anything precise.
### A note on live-API field mappings
Both the bulk-export importer and the live-API sync mappings
(`garmin_mcp/sync/daily_categories.py`, `garmin_mcp/sync/activities_sync.py`)
have been verified against a real account -- including a direct
field-by-field diff between CSV-imported and freshly API-backfilled data
for the same historical date. A few real discrepancies from that process
are now documented inline in the code and worth knowing about:
- `get_user_summary` (the live daily-health endpoint) returns a **flat**
structure -- unlike the bulk export's nested `allDayStress`/`bodyBattery`/
`respiration` objects, and with several different field names
(`averageStressLevel` vs. nested `allDayStress.aggregatorList[TOTAL]`,
`bodyBatteryHighestValue` vs. a `bodyBatteryStatList` lookup, etc.).
Once mapped correctly, values match the CSV export exactly for the same
date.
- `daily_stress_periods`' AWAKE/ASLEEP breakdown is CSV-only -- the live
endpoint only exposes a TOTAL-equivalent, so live sync writes just that
row and leaves any existing CSV-sourced AWAKE/ASLEEP rows alone.
- `training_status`/`fitness_trend` are numeric codes live (`7`, `1`, ...),
not the plain strings the bulk export gives (`"MAINTAINING"`,
`"DECREASING"`) -- there's no public code-to-string mapping, so expect
`source='api'` rows to look different in flavor from `source='csv_export'`
rows in this column specifically.
- `sleep`'s per-stage quality subscores (`deep_score`, `rem_score`, etc.)
and `restless_moment_count` aren't available from the live endpoint at
all (only an overall score, feedback string, and raw stage percentages)
-- left `NULL` on `source='api'` rows rather than approximated.
- `body_composition` and `hrv_daily` (nightly detail) fill in correctly
from live sync where the CSV export couldn't provide them.
If you spot a live-synced row that looks wrong, `SELECT ... WHERE
source='api'` vs. `source='csv_export'` for the same date is the fastest
way to compare and confirm before assuming it's a bug.
## Tests
```bash
pytest
```
Focused specifically on the two failure modes this project exists to avoid:
- `tests/test_idempotency.py`: re-running an import or sync never
duplicates rows; a partially-failed sync batch resumes from the right
date instead of silently skipping the gap or re-processing everything.
- `tests/test_rate_limiter.py`: exponential backoff math, persisted
cooldown blocking a fresh process from hammering the API again, and
that retries are capped with a clear logged failure rather than an
infinite loop or a silent give-up.
- `tests/test_retry.py`: transient-error retries (including the
2026-11-09 504 from the token endpoint), the outage breaker, login
retries that never retry bad credentials, whole-run retries, and mail.
TDQS
Scored across 9 tools
Each tool targets a distinct aspect of Garmin data: sync management, data retrieval for specific domains (activities, health, sleep, training), and direct SQL access. There is no functional overlap.
All tools use a consistent snake_case verb_noun pattern (e.g., get_sleep, list_activities, sync_now), with 'get' and 'list' as common prefixes, making the naming predictable.
9 tools is ideal for a Garmin data server: it covers core operations (sync, backfill, query) without being overwhelming or sparse. Each tool serves a clear purpose.
The set provides direct tools for activities, health metrics, sleep, and training trends, but lacks dedicated tools for backfilled categories like body composition or race predictions. However, the execute_sql tool allows ad hoc queries to fill these gaps.