Skip to main content
Glama

garmin-sync

Pulls your Garmin Connect data into a local SQLite database on a schedule, and serves it back to an AI assistant over MCP, read-only.

Your health history is otherwise only visible through Garmin's own app, one screen at a time. This puts it in a file you own, in a shape you can query.

mkdir -p data tokens logs                                    # Docker would make these root-owned
cp .env.example .env                                         # your Garmin login
docker compose run --rm garmin-sync python first_login.py    # once; handles 2FA
docker compose run --rm garmin-sync                          # pull
docker compose up -d garmin-mcp                              # optional query server

The shipped .env.example backfills from the start of 2025. Widen BACKFILL_START once you have seen a run work — every extra year is another ~5,800 requests to an API that rate-limits unofficial clients.

compose.yaml needs no editing. Everything is relative to the folder it sits in, so a clone runs where it stands.

What you end up with

Garmin Connect
      │  python-garminconnect (mobile SSO + curl_cffi TLS impersonation)
      ▼
   sync.py ──► garmin.db     raw JSON  +  normalised tables
                    │
                    └──────► mcp_server.py  (read-only, for an assistant)

Two layers, on purpose. raw_records keeps every API response verbatim, so a Garmin field change never loses data you already have. On top of that sit typed tables — activities, daily_summary, sleep, hrv, body_battery, training_readiness, weight — for querying. If a normaliser breaks on a changed payload the raw row is still stored; the two are independent by design.

Ten of the sixteen collectors are archived but not yet queryable. The second layer was only ever built for six of them:

Queryable — typed tables, visible to SQL and to the MCP server

stats, sleep, hrv, body_battery, training_readiness, weigh_ins

Archived only — captured as raw JSON, no typed table yet

all_day_stress, steps, spo2, respiration, intensity_minutes, floors, resting_hr, max_metrics, training_status, hydration

Nothing is lost — every one of those is fetched daily and stored complete, and you can dig it out of raw_records with SQLite's JSON functions. But your assistant cannot see it, and neither can a plain query, until someone writes the normaliser. Adding one is a small, self-contained job in collectors.py: read a stored payload, decide which fields matter, map them to a table.

Incremental. Each collector keeps a high-water mark, so a run fetches only the days it has not got. The mark advances only across an unbroken run of successes — a day that fails holds it back and is re-requested next run, rather than being stepped over and lost. Runs that end with days still owed report failure and name them.

A day that keeps failing cannot hold a collector back forever, though, or one day Garmin will never serve would stop the archive growing. After MAX_DAY_ATTEMPTS tries it is recorded in the sync_gaps table and stepped over. Losing a day quietly and abandoning one on the record are different things; select * from sync_gaps tells you which days are missing and why.

A reality check. Garmin has no official personal-data API and actively blocks third-party clients (Cloudflare TLS fingerprinting since March 2026). This leans on the community garminconnect library, which currently gets through. When a Garmin change breaks it, the fix is usually to bump garminconnect (and curl_cffi, which does the TLS impersonation) in requirements.txt and rebuild. Treat that as a when-not-if: those two are pinned for reproducibility, but they are the pins to move, because staying current is the whole point of them.

Related MCP server: Garmin MCP Server

Running it daily

garmin-sync is a one-shot container: it runs, it exits. Scheduling is left to your machine rather than baked in.

deploy/ has systemd units — garmin-sync.service and garmin-sync.timer — which is what the author uses. cron or any other scheduler works equally well; the container just needs invoking once a day.

Pick your timezone deliberately with TZ. Garmin days are local days, so it decides where one ends.

The MCP server

garmin-mcp exposes the database to an AI assistant with a small set of read-only tools — recent days, sleep, weekly trends, and a guarded query surface.

It has no authentication. Anything that can reach the port can read your entire health history. It therefore binds 127.0.0.1 unless you say otherwise. To expose it further, set MCP_BIND_ADDR to an address on a private mesh (Tailscale, WireGuard) or put an authenticating proxy in front. It warns on startup if it is bound anywhere but loopback.

Read-only rests on PRAGMA query_only, which SQLite enforces itself and which no SQL text can talk its way past. The single-statement SELECT guard and the row cap sit on top as defence in depth — the guard blanks out string literals before looking for write keywords, so searching for an activity called "Morning update" is not mistaken for one. It is not mode=ro, deliberately — a read-only handle cannot create the WAL -shm file, so it fails to open the database whenever no writer is active, which is almost always.

Configuration

Everything comes from .env. .env.example lists every setting the code reads, commented out at its default — the table below is just the ones you are most likely to touch.

GARMIN_EMAIL / GARMIN_PASSWORD

your Garmin Connect login

TZ

decides where a "day" ends. Default Etc/UTC

BACKFILL_START

how far back the first run reaches. Ships as 2025-01-01. Unset means about ten years — roughly 58,000 requests. Widen it deliberately

REQUEST_SLEEP

pause between calls. Raise it if you see 429

MAX_CONSECUTIVE_FAILURES

give up on a collector after this many failures in a row, so rate limiting ends the run instead of prolonging it. Default 10

MAX_DAY_ATTEMPTS

give up on one stubborn day after this many attempts, recording it in sync_gaps. Default 5

MCP_HOST

the address the server binds inside its own process. Loopback unless set

MCP_BIND_ADDR

Docker only: the host address the container's port is published on. Loopback unless set

MQTT_HOST

optional Home Assistant status publishing. Empty disables it

Credentials are mounted as a file rather than passed as environment variables, so Compose never tries to interpolate a $ inside your password.

Reading the data yourself

It is an ordinary SQLite file. Anything that opens one will do — DB Browser for SQLite, the sqlite3 CLI, pandas, a BI tool:

sqlite3 data/garmin.db \
  "select date, total_steps, resting_hr from daily_summary order by date desc limit 7"

If you are not using Docker

The code is plain Python and runs directly — Python 3.12 or newer (the Garmin client requires it), pip install -r requirements.txt, then python first_login.py once and python sync.py on a schedule. run_sync.bat and setup_scheduler.ps1 register a Windows Task Scheduler job for that arrangement. They are kept because they work, not because they are the recommended path.

Files

sync.py

the incremental engine — the thing your scheduler runs

collectors.py

what gets pulled and how it is normalised. Edit to add or drop data

database.py

SQLAlchemy models and engine

garmin_auth.py

login and token persistence

first_login.py

one-time interactive login, handles 2FA

mcp_server.py

read-only MCP query server

monitor.py

optional MQTT status for Home Assistant

config.py

every setting, read from .env

test_sync_gaps.py

tests for the high-water mark and gap handling

test_query_guard.py

tests for the MCP query guard

compose.yaml

standalone deployment. Start here

compose.garmin.yaml

the author's homelab stack. Absolute paths, external network — it will not run elsewhere

deploy/

systemd units and the author's deploy script

Troubleshooting

Login fails on a scheduled run. Tokens expired — re-run first_login.py. It is interactive, so it cannot happen unattended.

429 Too Many Requests. Garmin's rate limit. Wait, and raise REQUEST_SLEEP. Long backfills are the usual cause.

A whole data type is empty. Your device probably does not record it. The sync still succeeds and stores nothing for that type.

The container cannot write to data/. The image runs as uid 1000, which must own the mounted directories. Rebuild with docker compose build --build-arg APP_UID=$(id -u).

A run reports gaps. Some days failed and are still owed. The next run retries them; if it keeps happening, the log names the collector and date.

Home Assistant shows "degraded". The schedule is healthy but the archive is not complete — some days were given up on. select * from sync_gaps where abandoned = 1 lists them. Green means up to date and nothing missing.

Prior art

GarminDB is the mature option — more data types, FIT-file parsing, its own analysis notebooks. Use it if you want depth.

This exists for a narrower shape: a container that pulls yesterday and stops, a database an assistant can read directly, and a status signal for Home Assistant. That is a deployment preference, not a claim to be better.

Status

Published as reference and not actively maintained. It runs daily on the author's server; that is the extent of the testing. MIT.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • MCP server for Withings health data — sleep, activity, heart, and body metrics.

  • Hosted MCP server exposing US hospital procedure cost data to AI assistants

  • Garmin data in Claude & ChatGPT via the Garmin Health API. OAuth sign-in, no password sharing.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Object-Orient/garmin-sync'

If you have feedback or need assistance with the MCP directory API, please join our Discord server