garmin-mcp
Provides read-only access to Garmin Connect health and activity data, including sleep, HRV, body battery, training readiness, and weight, by syncing it to a local SQLite database.
Publishes sync status to Home Assistant via MQTT, enabling health monitoring of the data archive.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@garmin-mcpHow did I sleep last night?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 serverThe 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 |
|
Archived only — captured as raw JSON, no typed table yet |
|
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.
| your Garmin Connect login |
| decides where a "day" ends. Default |
| how far back the first run reaches. Ships as 2025-01-01. Unset means about ten years — roughly 58,000 requests. Widen it deliberately |
| pause between calls. Raise it if you see |
| give up on a collector after this many failures in a row, so rate limiting ends the run instead of prolonging it. Default 10 |
| give up on one stubborn day after this many attempts, recording it in |
| the address the server binds inside its own process. Loopback unless set |
| Docker only: the host address the container's port is published on. Loopback unless set |
| 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
| the incremental engine — the thing your scheduler runs |
| what gets pulled and how it is normalised. Edit to add or drop data |
| SQLAlchemy models and engine |
| login and token persistence |
| one-time interactive login, handles 2FA |
| read-only MCP query server |
| optional MQTT status for Home Assistant |
| every setting, read from |
| tests for the high-water mark and gap handling |
| tests for the MCP query guard |
| standalone deployment. Start here |
| the author's homelab stack. Absolute paths, external network — it will not run elsewhere |
| 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.
This server cannot be installed
Maintenance
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
- AlicenseBqualityAmaintenanceLocal-first MCP server that connects AI agents to your Garmin sleep, HRV, Body Battery, stress, training readiness and activities, keeping tokens on your machine.Last updated411,0428MIT
- Alicense-qualityBmaintenanceThis Model Context Protocol (MCP) server connects to Garmin Connect and exposes your fitness and health data to Claude and other MCP-compatible clients.Last updated1MIT
- Flicense-qualityDmaintenanceMCP server that connects AI assistants to Garmin Connect health and fitness data, enabling natural language queries about workouts, sleep, heart rate, and more.Last updated
- Alicense-qualityAmaintenanceRead-only MCP server that exposes Apple Health data (steps, workouts, sleep, etc.) from a local SQLite store, allowing AI agents to query health metrics without sending data to hosted services.Last updated3Apache 2.0
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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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