Garmin Connect MCP server
by felna-devops
README.md
# Garmin Connect MCP server
An [MCP](https://modelcontextprotocol.io) server for strength training on
Garmin Connect. Design a session in conversation with an AI assistant, push it
to your watch, and read back what you actually lifted.
> **Unofficial.** Not affiliated with, endorsed by, or supported by Garmin.
> Garmin and Garmin Connect are trademarks of Garmin Ltd. This project talks
> to Garmin's private workout-service endpoints through the reverse-engineered
> [python-garminconnect](https://github.com/cyberjunky/python-garminconnect)
> client, so Garmin can change or break it without notice. Use at your own
> risk, and check your own obligations under Garmin's terms of service.
Everything runs locally. Your credentials stay on your machine and only ever
go to Garmin.
## Why this exists
Garmin Connect will not import workout `.FIT` files — FIT import only works
for *completed activities*, not planned workouts. Your options are the Connect
web UI by hand, or the same private endpoint the web app itself calls. This
takes the second route and puts an MCP interface on it, so a workout can come
out of a conversation instead of twenty minutes of clicking.
## Tools
| Tool | Does |
|---|---|
| `search_exercises` | Search Garmin's ~1500-movement catalogue |
| `list_workouts` | Saved workouts and their IDs |
| `get_workout` | Read one workout's structure back |
| `create_workout` | Build and upload, with round-trip verification |
| `update_workout` | Replace contents in place, keeping the ID |
| `delete_workout` | Delete one workout by ID |
| `schedule_workout` | Put a workout on the calendar for a date |
| `list_programs` / `get_program` | Multi-day templates in `programs/` |
| `sync_program` | Create-or-update every workout in a program |
| `get_recent_sessions` | Completed sessions with set counts and volume |
| `get_exercise_history` | One movement over time: load, reps, volume, e1RM |
| `export_history` | Bulk CSV/JSON dump into `exports/` |
| `get_device_sync_status` | Devices and last sync, to confirm a push landed |
`delete_workout` is the only destructive tool.
## Setup
Requires Python 3.12 or newer (`garminconnect` 0.3.x needs it).
```bash
git clone https://github.com/YOUR-USERNAME/garmin-mcp.git
cd garmin-mcp
./setup.sh
```
`setup.sh` creates `.venv/` in the project directory, installs dependencies,
and copies `.env.example` to `.env`. Nothing is installed system-wide. If your
newest Python has no wheels for a dependency yet, override it:
```bash
PYTHON=python3.13 ./setup.sh
```
Fill in `.env` with your Garmin login, then authenticate once:
```bash
./.venv/bin/python garmin_login.py
```
This step is interactive on purpose. Garmin may ask for an MFA code, and the
MCP server has no terminal to ask on — it would simply hang. The login caches
tokens to `~/.garminconnect`, and the server reuses them silently from then
on. Re-run it only if the server reports the session expired.
Verify the build without touching the network:
```bash
./.venv/bin/python garmin_mcp.py --self-test
```
## Connect it to a client
### Claude Desktop
Add to `claude_desktop_config.json` — on macOS at
`~/Library/Application Support/Claude/`, on Windows at `%APPDATA%\Claude\`:
```json
{
"mcpServers": {
"garmin": {
"command": "/absolute/path/to/garmin-mcp/.venv/bin/python",
"args": ["/absolute/path/to/garmin-mcp/garmin_mcp.py"]
}
}
}
```
Use absolute paths and the venv's interpreter rather than a bare `python` —
the app does not inherit your shell's `PATH`. Restart the app afterwards.
### Anything else
It is a standard stdio MCP server, so any client works. To debug outside one:
```bash
npx @modelcontextprotocol/inspector ./.venv/bin/python garmin_mcp.py
```
## Programs
A program is a JSON file in `programs/` describing a multi-day template.
`sync_program` pushes every workout in it, matching on name — existing
workouts are updated in place, new ones created. See
[`programs/example.json`](programs/example.json) for a commented template.
```json
{
"name": "Example Upper/Lower",
"workouts": [
{
"name": "[EX] Upper A",
"warmup": "Two or three ramp-up sets.",
"blocks": [
{
"repeat": 4,
"steps": [
{ "exercise": "Barbell Bench Press", "reps": 5, "weight_kg": 60,
"note": "4x5-7. Add 2.5kg once you hit 7 on every set." },
{ "rest_seconds": 180 }
]
}
]
}
]
}
```
A **block** is a repeat group that runs `repeat` times. A **step** is either an
exercise (`exercise` + `reps`, optionally `weight_kg` and `note`) or a rest
(`rest_seconds`). A superset is one block holding both exercises with a short
rest between and a long rest at the end. Omit `weight_kg` for bodyweight work.
Omit `warmup` for the default warm-up step, or set it to `""` for none.
`programs/*.json` is gitignored apart from the example, so your own training
never lands in a commit.
## Things that will bite you
**Garmin silently blanks unrecognised exercise names.** The upload returns
200, and the exercise shows up nameless on the watch mid-session. This is why
`search_exercises` exists and why every name is resolved against the catalogue
before upload. Display names are hyphenated in ways nobody types — "Rope
Press-down", "Close-grip Chin-up" — so the search normalises punctuation and
understands shorthand like "db", "bb", "ohp", "rdl", "skullcrusher".
**The two halves of the API disagree on weight units.** The workout-service
takes `weightValue` in kilograms, despite `weightUnit` carrying
`"factor": 1000.0`. Activity payloads — what you lifted — report grams. Both
are handled; don't "fix" either.
**Round-trip verification cannot catch a unit error.** It compares what Garmin
stored against what was sent, and Garmin faithfully stores whatever you send.
When grams were being sent, the check passed and Connect displayed
"75,000 kg". Eyeball your first upload in Connect.
**Update, don't delete-and-recreate.** Keeping the workout ID means the watch
treats the change as an edit. Recreating makes it drop the old workouts and
pull down new ones, which is where stale duplicates come from.
**429 and 401 look the same.** Garmin rate limits logins per IP, aggressively.
When the first login transport is throttled, `garminconnect`'s fallback
reports a misleading 401. If you saw 429 just before, it is throttling — wait
30–60 minutes, and don't retry in a loop, which extends the block. To rule out
a genuinely wrong password: `garmin_login.py --check-env`.
## Layout
```
garmin_mcp.py the server — tool definitions
garmin_core.py auth, payload building, verification, set parsing
garmin_login.py one-time interactive login (MFA lives here)
programs/ multi-day templates as JSON
setup.sh creates .venv and installs dependencies
```
## Licence
MIT — see [LICENSE](LICENSE).
Built on [python-garminconnect](https://github.com/cyberjunky/python-garminconnect)
(MIT) and the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
(MIT).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues