garmin-mcp
A local, single-user, read-only MCP serverthat gives Claude Code access to your own Garmin health and training data. Itexposes six composite tools — health snapshot, training status, recent runs,run detail, body metrics and training analysis — so Claude can ground recoveryadvice and workout recommendations in your actual sleep, HRV, training load,and run history instead of guessing. Everything Garmin returns is cachedday-by-day in a local SQLite database, so repeated questions don't repeatedlyhit Garmin's servers. A local dashboard renders the result.

Synthetic demo data, not real health data — generated from a fabricated8-week training block by scripts/make_demo_screenshot.py, which iscommitted and runnable end to end:
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 thisrepo's own analysis code, not typed in independently — seescripts/demo_payloads.py and tests/test_demo_payloads.py. Seetests/js/fixtures/ for the (separate, simpler) fixtures the browser testsuite itself uses.
Requirements
- Python 3.12 or newer (this machine runs 3.14)
- A Garmin Connect account
- Optionally, the
claudeCLI (to auto-register the MCP server —install.shlooks for it onPATH, then~/.local/bin,/opt/homebrew/bin, and/usr/local/bin)
Install
git clone https://github.com/danielsuri/garmin-mcp.git
cd garmin-mcp
./install.sh
Then run the one-time login (details below):
.venv/bin/garmin-mcp login [email protected]
install.sh creates .venv, installs the package into it, finds theclaude CLI and registers the server at user scope if found, and symlinksthe garmin-insights skill into ~/.claude/skills/. It's idempotent — safeto re-run any time; it never clobbers an existing venv, registration, orskill 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 thefull rundown.
The login step is deliberately separate and manual: Garmin requires aninteractive login the first time (email, password, and an MFA code if youhave two-factor enabled), and it must run in a real terminal — it readsyour password with getpass, which needs an actual TTY and fails withEOFError under a non-interactive shell or piped subprocess. install.shwill offer to run it for you if your shell is interactive, and skips itcleanly otherwise so a scripted/CI install never hits an unannounced passwordprompt.
On success, tokens are written to ~/.garminconnect. They're good for roughlya year; after that (or if Garmin invalidates them) re-run login.
Check the install
.venv/bin/garmin-mcp doctor
doctor checks three things: that the stored tokens are accepted, that a livecall 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 authfails, 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 doit by hand instead (or if it couldn't find claude):
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 sessionwill 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, andthe tools appear only when you're working inside this repo. User scope makesthem available in every project, which is what you want for a personal healthserver.
Note the registration is an absolute path into this repo's venv, so movingor 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/awakeminutes, sleep score), HRV, body battery (the day's true high and low), restingheart rate, and stress over the window, plus rolling baselines. Use this tojudge how recovered you are.get_training_status(weeks=4)— acute and chronic training load, theacute:chronic load ratio, VO2max, and training readiness score per day. Usethis to judge whether training load is ramping safely.get_recent_runs(n=10)— your lastnruns with pace (average,moving, grade-adjusted, best), heart-rate zones, training effect andtraining load, VO2max, fastest splits, PR flag, calories, body-batterycost 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: everythingget_recent_runsreports for that run, plus running dynamics (groundcontact 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'styped splits). Omitactivity_idfor the most recent run found in thelast 56 days. Use this to deep-dive a specific run. Costs one extraGarmin API call beyondget_recent_runs(typed splits, for run/walkdetection) — each of the other fields comes from the same activity recordget_recent_runsalready fetches.get_body_metrics(days=30)— weight, body fat, steps, calories, floorsclimbed, and intensity minutes, with simple weight and step trends.get_training_analysis(weeks=8)— the marathon-block view: within-runform indicators (efficiency factor, aerobic decoupling, ground-contact-timedrift, duty factor, split pattern) for every run in the window, plusacross-block trends (weekly mileage, ramp rate with the base it was computedfrom, HR-zone polarization, efficiency and decoupling trend, recoverycoupling, VO2max trend). It computes; it does not advise. Costs two extraGarmin API calls per run it hasn't seen before (laps and typed splits),both cached permanently. This is what the dashboard's split board andeight-week panel are drawn from.
Arguments are clamped to a range the server can actually serve — days to1–365, weeks to 1–52, n to 1–100, activity_id to a non-negative64-bit value — so an out-of-range value narrows or widens to the nearestbound (or, for an activity_id that matches nothing, yields a cleanall-null result) rather than failing.
Every tool returns a partial: true flag when it couldn't be certain all ofits data is fresh or complete — see Troubleshooting below.
The dashboard
.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 andprescription in large type at the top, then the split board — every run inthe last eight weeks as a bar whose width is its distance and whose colouris 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 blocknumbers and the focus notes.
It binds 127.0.0.1 only and has no authentication, because it serves yourcomplete health record to anything that can reach it. Don't put it behind atunnel 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 missingvalue renders as a dash and never as zero, a ramp percentage is always shownnext to the mileage it was computed from (a "+947.7%" week off a 1.6 km baseis not the emergency it looks like), and a comparison the analysis layerrefused 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 skillupdate 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:
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 oftruth. /garmin-insights becomes available in any new Claude Code session afterthat (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 schedulerand nothing automatic: ask for it.
In a Claude Code session, run
/garmin-insights— or just ask"refresh my dashboard insights". The skill carries the schema plus therules that keep the prose honest: every figure must trace to the payload,the engine's refusals are respected rather than talked over, and thinsamples are stated rather than hidden.Claude calls
get_training_analysis,get_health_snapshotandget_recent_runs, then writes~/.garmin-mcp/insights.jsonin this shape:{"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": "…"}}Reload the dashboard.
The page always shows how old that file is ("insights generated 2 h ago"), andonce it passes 24 hours it says so plainly instead of presenting yesterday'sreasoning as this morning's. If the file is missing or malformed, the pageshows an empty state explaining this workflow rather than an error — the livenumbers below it still render.
Capturing fixtures
.venv/bin/garmin-mcp capture-fixtures
This calls each underlying Garmin endpoint directly and writes the raw JSONresponses to tests/fixtures/. It's a diagnostic tool for verifying Garmin'sactual response shapes against what the assemblers expect (seedocs/api-field-map.md). The files it writes contain real health data andare gitignored (tests/fixtures/*.json) — never commit them, and don't printtheir contents anywhere shareable.
Testing
.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 viatests/test_js_units.py) — pure-function tests forsrc/garmin_mcp/dashboard/static/app.js(formatters, date parsing, effortcalibration) run under node's built-innode:test. Zero dependencies:no npm install, nopackage.json.node --test 'tests/js/**/*.test.js'runs this layer standalone for a fast front-end-only loop while iteratingonapp.js— a bare directory path (node --test tests/js) does notreliably resolve on node v26, so the glob form is what both this commandandtests/test_js_units.pyuse. Skips (not fails) ifnodeisn't onPATH.Tier 2, browser layout (
tests/test_dashboard_browser.py) — reallayout checks (bar widths, grid wrapping, 200% text-zoom overflow) drivenwith Playwright against a throwaway local serverserving the static page plus fixtures fromtests/js/fixtures/(synthetic,committed — no health data). Requires thedevextra and a one-timebrowser download:.venv/bin/pip install -e ".[dev]" .venv/bin/playwright install chromium # one-time, ~150 MB downloadSkips cleanly (not fails) if
playwrightisn't installed, or if it'sinstalled butplaywright install chromiumhasn't been run yet — both afresh clone and a CI box without either dependency still pass the suite,they just see fewer tests run. This layer binds127.0.0.1on anOS-assigned ephemeral port (never a fixed port, never8765) and nevercalls Garmin — see the module docstring for why it's the one place in thesuite that opens a socket at all.
Troubleshooting
- Auth errors / "Garmin rejected the stored tokens" — your tokens expiredor were revoked. Re-run
garmin-mcp login [email protected]from a realterminal. partial: truein a tool's response — Garmin rate-limited a request ora connection failed transiently. The tool still returns whatever it hascached; treat the numbers as possibly incomplete or stale and try againlater 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 inget_body_metricsonly populate on days you actually stepped on aconnected scale.
Licence
MIT — see LICENSE.