garmin-mcp
README.md
# garmin-mcp
Two ways to coach yourself with your Garmin (and TrainingPeaks) data and Claude:
- **An MCP server** that gives Claude (web, desktop, mobile) read access to your Garmin Connect
data: activities, sleep, HRV, VO2 max, training readiness and more. It uses streamable HTTP with
MCP OAuth 2.1, and GitHub handles login.
- **Rabbit**, a coach app for your phone's Home Screen, for people who won't set up an MCP
connector. Sign in with Google, connect Garmin, and talk to it like a coach. It reviews your
training history before asking you anything, reads your training plan (PDF or screenshots) and
tracks each week against it, and sends a morning brief and a note after each workout.

This is a personal project. I run one instance for myself and a friend, and each deployment is
self-hosted on a small GCP VM with your own Claude API key.
> **Heads-up:** Garmin has no personal API. This uses the unofficial
> [`garminconnect`](https://github.com/cyberjunky/python-garminconnect) library,
> which talks to Garmin's private endpoints. That is against Garmin's terms of
> use and can break without notice. It's fine for personal use, but expect occasional breakage.
## Tools
| Tool | What it does |
|---|---|
| `recent_activities(days=7, sport=None)` | Returns a compact list of recent activities: date, sport, duration, distance, HR, pace or speed, power, swim pool length and strokes, training effect, and ID. `sport` accepts `run`, `bike`, `swim`, `brick`, `strength`, `walk` or `hike`. |
| `search(query)` | Finds `garminconnect` methods (name, signature, summary, read/write). The index is built by introspecting the installed library at startup. |
| `execute(method, args, full=false)` | Calls one allowlisted method. Reads are allowed. Writes are blocked unless `GARMIN_ALLOW_WRITES=true`. Credential, raw-request, file-path, GraphQL and binary methods are always blocked. Bulky time series are dropped unless `full=true`, and every response is size-capped with a note when it's truncated. |
With `TRAININGPEAKS_ENABLED=true` (see [TrainingPeaks](#trainingpeaks-optional)), four more:
| Tool | What it does |
|---|---|
| `tp_workouts(start, end)` | The TrainingPeaks calendar for a date range (default last 7 days through the next 7, max 90): planned and completed workouts with sport, title, duration, distance, TSS and `workout_id`. |
| `tp_create_workout(date, sport, title, ...)` | Puts a planned workout on the calendar, optionally with a start time, description, duration, distance, TSS and a **structured workout** (steps and repeats as % of FTP, threshold HR or threshold pace). Duration and TSS are computed from the structure when you don't give them. TrainingPeaks then syncs it to your watch through its Garmin connection. |
| `tp_delete_workout(workout_id)` | Deletes a planned workout. Anything with completed data is refused. |
| `tp_upload_garmin_activity(activity_id)` | Copies a finished Garmin activity's original FIT file into TrainingPeaks as a completed workout on the right day. |
## Layout
```
src/garmin_mcp/
config.py env settings (PUBLIC_BASE_URL -> issuer, /mcp, /github/callback)
tokens.py TokenStore + EncryptedFileTokenStore (Fernet, 0700/0600, atomic)
providers/base.py GarminProvider interface + caller-safe errors
providers/unofficial.py garminconnect implementation (backoff, token write-back, Cloudflare detection)
providers/official.py stub for Garmin's Connect Developer Program
registry.py introspected method index + allowlist
trainingpeaks.py TrainingPeaks client (cookie -> bearer), structured-workout builder, tools
tp_login_cli.py garmin-mcp-trainingpeaks-login (stores the session cookie, encrypted)
shaping.py bulky-field removal + size cap
resilience.py TTL cache + per-user rate limiter
tools.py search / execute / recent_activities (transport-independent)
toolspec.py tool descriptions shared by the MCP server and Rabbit's agent
poller.py workout-sync poller: new activities -> Claude Code routine
auth.py MCP OAuth authorization server backed by GitHub
webauth.py Rabbit web app: Google sign-in + cookie sessions
webpush.py Rabbit web app: Web Push (VAPID + aes128gcm), SSRF-guarded
webapp.py Rabbit web app: /app routes and static frontend
appstore.py small Fernet-encrypted JSON docs (sessions, per-user data)
agent.py Rabbit's coach agent: Claude tool loop over GarminService, streamed
coaching.py Rabbit: athlete profile, goals, plans, threads and history
history.py Rabbit: training_history (months of activity in one tool call)
weekplan.py Rabbit: "this week" planned vs done
planbuilder.py Rabbit: plan generator (phases, sessions, Garmin workout JSON)
plans.py Rabbit: built plans, session edits, Garmin calendar sync
events.py Rabbit: race calendar, main event <-> goal, web lookup of event details
proactive.py Rabbit: morning brief, workout notes, reconnect nudge
webchat.py Rabbit: chat turns that outlive the request
attachments.py Rabbit: validated image/PDF/text uploads
garminlogin.py Rabbit: user-initiated Connect Garmin flow
server.py MCPServer app, uvicorn on 127.0.0.1
login_cli.py garmin-mcp-login
web/ Rabbit frontend (Vite + React, ported from Dolly); builds into src/garmin_mcp/web_dist
brand/ Rabbit logo (SVG)
infra/ setup.sh, bootstrap scripts, systemd units, Caddyfile
deploy.sh push + restart over IAP (--rabbit for the web app)
scripts/smoke_live.py live check against your real account (not CI)
```
## Local development
```bash
uv sync # Python 3.12 + pinned deps from uv.lock
uv run pytest # unit + OAuth end-to-end tests (Garmin and GitHub mocked)
# One-off local key (keep it out of git):
export GARMIN_MCP_FERNET_KEY=$(uv run python -c 'from garmin_mcp.tokens import generate_key; print(generate_key())')
export PUBLIC_BASE_URL=http://localhost:8000 DATA_DIR=./data
# Log in to Garmin locally (prompts for email, password and MFA). Tokens go to ./data/tokens/<user>.fernet
uv run garmin-mcp-login --user holden
# Run the server without OAuth (refused unless PUBLIC_BASE_URL is localhost):
DEV_NO_AUTH_USER=holden uv run garmin-mcp
```
Note that a local login comes from your home IP. Garmin rate-limits logins per IP
and fingerprint, so do the real login on the VM (below) rather than copying
tokens around.
### Testing with MCP Inspector
```bash
DEV_NO_AUTH_USER=holden uv run garmin-mcp # terminal 1
npx @modelcontextprotocol/inspector # terminal 2
```
In the Inspector, choose transport **Streamable HTTP** and URL `http://localhost:8000/mcp`.
To exercise the real OAuth flow locally, create a second GitHub OAuth App with callback
`http://localhost:8000/github/callback`, then drop `DEV_NO_AUTH_USER` and set
`GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `ALLOWED_GITHUB_USERS`.
Inspector's loopback redirect is allowed by `OAUTH_ALLOW_LOOPBACK_REDIRECTS`.
## GCP setup (one time)
**Easiest:** open the guided walkthrough in Cloud Shell (runs everything in your browser, no local
gcloud needed):
[Open in Cloud Shell](https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/holdenstirling/garmin-mcp&cloudshell_tutorial=infra/cloudshell/tutorial.md&show=terminal).
It uses a free `<ip>.sslip.io` hostname, so no domain is required. The manual steps follow.
Prerequisites: `gcloud` authenticated, a domain you control, and a GitHub account.
1. `cp infra/config.env.example infra/config.env` and fill it in. Get your GitHub numeric ID with
`gh api users/<your-login> --jq .id`. The allowlist uses the numeric ID because
usernames can be renamed and re-registered by someone else.
2. Create a **GitHub OAuth App** (Settings → Developer settings → OAuth Apps):
- Homepage: `https://<DOMAIN>`
- Callback: `https://<DOMAIN>/github/callback`
- Copy the client ID into `infra/config.env`, and keep the client secret handy.
3. Run `./infra/setup.sh`. It:
- enables the Compute, Secret Manager and IAP APIs;
- creates the `garmin-mcp-vm` service account with **no project roles**, only `secretAccessor` on the two secrets;
- generates the Fernet key straight into Secret Manager (it never touches disk) and stores the GitHub secret, which it prompts for;
- reserves a static IP;
- opens firewall rules for 80/443 to the world and 22 only from IAP (`35.235.240.0/20`);
- creates an e2-small Shielded VM with OS Login.
It offers to delete the default network's `default-allow-ssh` rule. Without that, SSH is not IAP-only.
4. Point an **A record** for `<DOMAIN>` at the printed IP and wait until it resolves.
5. Run `./deploy.sh --bootstrap`. This:
- uploads the code;
- installs Caddy, uv 0.12.21 and Python 3.12 (uv-managed, under `/opt/uv`);
- creates the `garmin-mcp` system user and its directories;
- writes `/etc/garmin-mcp/env` from `.env.example` using your config;
- installs the systemd unit and Caddyfile, then starts everything.
### Behind Cloudflare
`BEHIND_CLOUDFLARE=true` in `infra/config.env`. With Cloudflare in front, the deployment looks like this:
- **DNS:** a proxied A record for `<DOMAIN>` (e.g. `garmin.example.com`) pointing at the VM's static IP.
- **TLS:** a Cloudflare Origin CA certificate (ECC, valid until 2041) in `/etc/caddy/certs/`.
The key was generated on the VM and never left it; only the CSR was sent to Cloudflare.
Caddy uses it automatically when the files exist (no ACME).
- **Cloudflare rules** (in the domain's zone, scoped to this host only):
- A Configuration Rule sets SSL to **Full (strict)** and turns the browser integrity check off.
- A custom WAF **skip** rule applies only to Anthropic's egress range (`160.79.104.0/21`), so
bot and AI-bot protection don't block the connector's server-to-server calls.
- **GCP firewall:** only Cloudflare's IPv4 ranges can reach port 443; port 80 is closed.
If Cloudflare changes its ranges, re-run `infra/setup.sh`.
To SSH in: `gcloud compute ssh <VM_NAME> --zone <ZONE> --tunnel-through-iap`. Your account needs
`roles/iap.tunnelResourceAccessor` and `roles/compute.osAdminLogin`.
## Garmin login on the VM (bootstrap and re-auth)
```bash
gcloud compute ssh garmin-mcp --zone <ZONE> --tunnel-through-iap
sudo garmin-mcp-login --user holden
```
- The command prompts for your email, then your password (not echoed), then the MFA code if Garmin asks for one.
- It makes **one** login attempt. Internally `garminconnect` tries its own strategy chain once, which can take up to a minute. **Nothing retries automatically.** If Garmin returns 429 or a Cloudflare challenge, wait before trying again.
- The password exists only in the CLI process's memory and is never written anywhere. Only Garmin's OAuth tokens are stored, Fernet-encrypted, in `/var/lib/garmin-mcp/tokens/<user>.fernet`.
- The running server notices new tokens on its next request, so no restart is needed.
Then run the live smoke test:
```bash
sudo -u garmin-mcp bash -c 'set -a; . /etc/garmin-mcp/env; set +a; \
/opt/garmin-mcp/current/.venv/bin/python /opt/garmin-mcp/current/scripts/smoke_live.py --user holden'
```
**Re-authenticating:** when a tool says "Garmin authorization … is not usable … re-run
garmin-mcp-login", run the same `sudo garmin-mcp-login --user holden`.
**Backups:** there are none, on purpose. If the token file or Fernet key is lost,
re-running the login CLI is the recovery path.
**If every API call returns 403 after a successful login:** Garmin's edge is rejecting the
plain-`requests` TLS fingerprint used for API calls from datacenter IPs
([python-garminconnect#444](https://github.com/cyberjunky/python-garminconnect/issues/444)).
Set `GARMIN_API_TLS_IMPERSONATION=true` in `/etc/garmin-mcp/env`, then run
`sudo systemctl restart garmin-mcp` and re-run the login.
## TrainingPeaks (optional)
TrainingPeaks' official API is only open to approved partners, so, like the Garmin side, this
uses the private API the TrainingPeaks web app calls (`tpapi.trainingpeaks.com`). It works with
any account, but it's unofficial and can break without notice. Some reads need Premium.
1. In `/etc/garmin-mcp/env` set `TRAININGPEAKS_ENABLED=true`, and `TRAININGPEAKS_ALLOW_WRITES=true`
if Claude should be able to create, delete and upload workouts. Restart:
`sudo systemctl restart garmin-mcp`.
2. Sign in at [app.trainingpeaks.com](https://app.trainingpeaks.com), open your browser's
developer tools, go to Application (Chrome) or Storage (Firefox) → Cookies →
`https://app.trainingpeaks.com`, and copy the value of `Production_tpAuth`.
3. On the VM: `sudo garmin-mcp-trainingpeaks-login --user holden` and paste it (input is hidden).
It's checked against TrainingPeaks once, then saved Fernet-encrypted to
`$DATA_DIR/trainingpeaks/<user>.fernet`. The server swaps it for a one-hour bearer token
that's only kept in memory.
The cookie usually lasts a few weeks. When it expires the tools say so; repeat steps 2 and 3.
`--delete` removes it. If your Garmin account already auto-syncs to TrainingPeaks, finished
workouts show up there on their own, so you'll mostly want `tp_create_workout` (plan a session,
then it lands on your watch) and `tp_upload_garmin_activity` only for ones that didn't sync.
Example prompts in Claude: *"Put a 4x8 min threshold ride at 95-100% FTP on my TrainingPeaks for
Thursday at 6am"*, *"What's on my TrainingPeaks this week, and how does it compare to what I
actually did on Garmin?"*, *"Upload yesterday's open-water swim to TrainingPeaks."*
## Deploying updates
IAP uploads are slow unless gcloud's Python has NumPy. The deploy scripts use
`~/.local/share/gcloud-python` when it exists (see `infra/gcloud-python.sh`):
`uv venv --python 3.12 ~/.local/share/gcloud-python && VIRTUAL_ENV=~/.local/share/gcloud-python uv pip install numpy`.
```bash
uv lock # only when you change dependencies
./deploy.sh # upload working tree, uv sync --frozen, switch release, restart, health check
./deploy.sh --rollback # back to the previous release
```
Releases live in `/opt/garmin-mcp/releases/<timestamp-sha>`. They are owned by root and read-only to the service, and the last 3 are kept.
Logs: `journalctl -u garmin-mcp -f` (JSON lines; no tokens or health payloads).
## Adding to claude.ai as a custom connector
1. Go to claude.ai → **Settings → Connectors → Add custom connector**.
2. Enter the URL `https://<DOMAIN>/mcp`. Leave the OAuth client ID and secret empty, because Claude registers itself via DCR.
3. Click **Connect**. You'll be sent to GitHub. Approve, and you land back in Claude.
4. The connector then works on web, desktop and mobile. Try "What did I do this week?" or
"Show HRV and sleep for the last 5 nights vs my long run on Sunday".
Access tokens last 1 hour and refresh tokens last 30 days, rotated on every use. Removing your ID from
`ALLOWED_GITHUB_USERS` (then restarting) revokes access immediately.
## Adding more users later
1. Add `<github_id>:<user_id>` to `ALLOWED_GITHUB_USERS` in `/etc/garmin-mcp/env` and restart.
2. `sudo garmin-mcp-login --user <user_id>` with their Garmin credentials, over SSH, with them present for MFA.
Each user's tokens, cache entries and rate-limit bucket are keyed by `user_id`.
## Workout-sync poller (optional)
A background thread in the server checks Garmin for new activities and fires a
[Claude Code routine](https://claude.ai/code/routines) through its API trigger, once per batch,
with one line per activity (`id | sport | start time local | duration | distance`). It runs every 5 min
from 5 to 9:55am and hourly otherwise, in `America/Denver` (`POLL_*` in `.env.example`).
Each poll logs one `poll` line with `checked`, `new` and `triggered`.
- **First run** only records the newest activity ID; it doesn't fire.
- **At most once per activity.** The last-seen ID advances only after the routine returns 200, so
failures retry on the next poll. If the outcome is unknown (a timeout after sending, or a crash
mid-fire), it advances anyway and logs `routine_fire_outcome_unknown`, because `/fire` has no
idempotency key and retrying could start a duplicate review.
- **Self-healing.** Errors never stop the loop. Garmin or routine failures back off from 5 min
up to 1 h, a routine 429 waits for `Retry-After`, and a Garmin re-auth error pauses polling until
`garmin-mcp-login` writes new tokens. A file lock allows one poller per data dir, and
`POLL_MAX_FIRES_PER_DAY` caps fires as a circuit breaker.
- **Multisport:** child activities (with a `parentId`) are skipped and the parent is reported.
State: `/var/lib/garmin-mcp/poller/state.json`. Delete it to re-baseline.
To enable it:
1. Create the routine at claude.ai/code/routines, add an API trigger, and copy its ID and token.
2. Store the token, piped straight from the prompt into Secret Manager:
```bash
gcloud secrets create garmin-mcp-routine-token --project <PROJECT> --replication-policy=automatic
read -rs T; printf '%s' "$T" | gcloud secrets versions add garmin-mcp-routine-token --project <PROJECT> --data-file=-
gcloud secrets add-iam-policy-binding garmin-mcp-routine-token --project <PROJECT> \
--member=serviceAccount:garmin-mcp-vm@<PROJECT>.iam.gserviceaccount.com \
--role=roles/secretmanager.secretAccessor --condition=None
```
3. Add `POLL_USER_ID=holden`, `ROUTINE_ID=<id>`, `ROUTINE_TOKEN_SECRET_NAME=garmin-mcp-routine-token`
and `POLL_DRY_RUN=true` to `/etc/garmin-mcp/env`, run `sudo systemctl restart garmin-mcp`, and watch
`journalctl -u garmin-mcp -f | grep poll`. A dry run logs the text it would send and saves nothing.
4. When the output looks right, set `POLL_DRY_RUN=false` and restart.
## Rabbit web app (optional)
Rabbit is a phone-friendly coach app at `$PUBLIC_BASE_URL/app/` for people who won't set up an MCP
connector: Google sign-in, Web Push, a Connect Garmin page, and a coach agent (Claude Haiku) with
the same Garmin tools plus `training_history`. It keeps a coach's log (morning briefs, workout
notes), separate chats by topic, the athlete's goal and plan, and a "this week" view of planned vs
done. Access is an allowlist of Google accounts. The design notes are in `docs/plans/web-app.md`.
**It runs as a separate service** so testing it can never take down the MCP connector:
`rabbit.service` on 127.0.0.1:8001, its own system user (`rabbit`), data dir (`/var/lib/rabbit`),
release dir (`/opt/rabbit`), env file (`/etc/rabbit/env`) and Fernet key. Caddy sends `/app` and
`/app/*` to it and everything else to the MCP server; if Rabbit is down, only `/app` returns 502.
The same codebase runs in both, with `MCP_ENABLED=false` in Rabbit's env (no `/mcp`, no GitHub
OAuth). Garmin tokens for Rabbit users live only in Rabbit's data dir, so each user's tokens are
still refreshed by exactly one process.
### Rabbit local dev
`scripts/dev-rabbit.sh` runs Rabbit on its own (`MCP_ENABLED=false`, port 8001), never touching
the MCP server. Local keys, sessions and push subscriptions live in `./data/rabbit-dev`.
- **Mac:** `scripts/dev-rabbit.sh mac`, then open http://localhost:5173/app/ (Vite hot reload).
Google is skipped (`DEV_NO_AUTH_USER=holden`, localhost only). Chrome allows notifications on
localhost, so push works on the Mac too.
- **Phone:** iOS only allows service workers, Web Push and secure cookies over real HTTPS, and
Google rejects LAN IPs and `.local` redirect URIs. So phone mode exposes the Mac through a
Cloudflare Tunnel on a fixed subdomain, with real Google sign-in (the dev bypass is refused for
non-localhost URLs). One-time setup:
```bash
brew install cloudflared
cloudflared tunnel login # browser: pick your zone
cloudflared tunnel create rabbit-dev
cloudflared tunnel route dns rabbit-dev rabbit-dev.<your-domain>
```
Add `https://rabbit-dev.<your-domain>/app/auth/google/callback` as a redirect URI on the Google
OAuth client, then create `data/rabbit-dev/phone.env` (mode 600):
```bash
RABBIT_DEV_URL=https://rabbit-dev.<your-domain>
CLOUDFLARE_TUNNEL=rabbit-dev
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
ALLOWED_GOOGLE_USERS=you@gmail.com:holden
```
Run `scripts/dev-rabbit.sh phone`. The frontend rebuilds on save; refresh the phone.
On the VM:
1. `./infra/setup.sh` creates `garmin-mcp-google-client-secret` and `garmin-mcp-anthropic-api-key`
(empty), `garmin-mcp-vapid-private-key` and `garmin-mcp-rabbit-fernet-key` (generated) and grants
the VM access. Store the Claude API key (from the Console; set a spend limit there too):
```bash
pbpaste | tr -d '\n' | gcloud secrets versions add garmin-mcp-anthropic-api-key --project <PROJECT> --data-file=-
```
2. Google Cloud console → **Google Auth Platform**: External, scopes `openid`, `email` and
`profile` only. Either add each user under **Audience → Test users** (up to 100), or
**Publish app** (no verification needed for these scopes; the allowlist still limits access). **Credentials →
Create OAuth client ID → Web application**, authorized redirect URI
`https://<domain>/app/auth/google/callback`. Store the secret:
```bash
read -rs S; printf '%s' "$S" | gcloud secrets versions add garmin-mcp-google-client-secret --project <PROJECT> --data-file=-
```
3. `./deploy.sh --rabbit --bootstrap`: creates the user, dirs, `/etc/rabbit/env` (from
`infra/rabbit.env.example`) and the unit, and re-renders the Caddyfile (validated first; a
graceful reload that doesn't restart the MCP server). The first start fails until step 4.
4. Fill in `/etc/rabbit/env` and restart Rabbit (or edit the file on the VM by hand):
```bash
scripts/rabbit-prod-env.sh <google_client_id> "you@gmail.com:you,friend@gmail.com:friend" mailto:you@example.com
```
5. Later updates: `./deploy.sh --rabbit` (builds `web/` locally with bun; restarts only Rabbit).
`./deploy.sh --rabbit --rollback` rolls back only Rabbit. Logs: `journalctl -u rabbit -f`.
On an iPhone: open the URL in Safari → Share → **Add to Home Screen**, open Rabbit from the Home
Screen, sign in, connect Garmin, then turn on notifications in Settings and **Send a test notification**.
Removing someone from `ALLOWED_GOOGLE_USERS` (and restarting) revokes their sessions immediately.
## Switching to Garmin's official API later
Implement `OfficialApiProvider` (`providers/official.py`) against the Connect Developer
Program's OAuth and its push-based Health/Activity APIs, then swap it in `server.create_app`.
The tools depend only on `GarminProvider`, `GarminSession` and the method registry.
## Configuration
Every variable is listed and explained in [`.env.example`](.env.example).
## License
[MIT](LICENSE). Includes MIT-licensed code from
[anlinguist/garmin-mcp](https://github.com/anlinguist/garmin-mcp) (see LICENSE). The TrainingPeaks endpoints and structured-workout format were worked out with
reference to [JamsusMaximus/TrainingPeaks-MCP](https://github.com/JamsusMaximus/TrainingPeaks-MCP)
(MIT). Garmin, TrainingPeaks and Ironman are trademarks of their owners; this project isn't
affiliated with any of them.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues