garmin-mcp
Provides read access to Garmin Connect data, including activities, sleep, HRV, VO2 max, and training readiness. Tools include listing recent activities, searching available Garmin methods, and executing allowlisted read operations against Garmin Connect.
Click on "Deploy 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 and what's my training readiness?"
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-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
garminconnectlibrary, 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 |
| 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. |
| Finds |
| Calls one allowlisted method. Reads are allowed. Writes are blocked unless |
With TRAININGPEAKS_ENABLED=true (see TrainingPeaks), four more:
Tool | What it does |
| 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 |
| 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. |
| Deletes a planned workout. Anything with completed data is refused. |
| Copies a finished Garmin activity's original FIT file into TrainingPeaks as a completed workout on the right day. |
Related MCP server: garmin-mcp
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
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-mcpNote 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
DEV_NO_AUTH_USER=holden uv run garmin-mcp # terminal 1
npx @modelcontextprotocol/inspector # terminal 2In 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.
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.
cp infra/config.env.example infra/config.envand fill it in. Get your GitHub numeric ID withgh api users/<your-login> --jq .id. The allowlist uses the numeric ID because usernames can be renamed and re-registered by someone else.Create a GitHub OAuth App (Settings → Developer settings → OAuth Apps):
Homepage:
https://<DOMAIN>Callback:
https://<DOMAIN>/github/callbackCopy the client ID into
infra/config.env, and keep the client secret handy.
Run
./infra/setup.sh. It:enables the Compute, Secret Manager and IAP APIs;
creates the
garmin-mcp-vmservice account with no project roles, onlysecretAccessoron 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-sshrule. Without that, SSH is not IAP-only.Point an A record for
<DOMAIN>at the printed IP and wait until it resolves.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-mcpsystem user and its directories;writes
/etc/garmin-mcp/envfrom.env.exampleusing 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)
gcloud compute ssh garmin-mcp --zone <ZONE> --tunnel-through-iap
sudo garmin-mcp-login --user holdenThe 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
garminconnecttries 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:
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).
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.
In
/etc/garmin-mcp/envsetTRAININGPEAKS_ENABLED=true, andTRAININGPEAKS_ALLOW_WRITES=trueif Claude should be able to create, delete and upload workouts. Restart:sudo systemctl restart garmin-mcp.Sign in at 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 ofProduction_tpAuth.On the VM:
sudo garmin-mcp-trainingpeaks-login --user holdenand 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.
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 releaseReleases 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
Go to claude.ai → Settings → Connectors → Add custom connector.
Enter the URL
https://<DOMAIN>/mcp. Leave the OAuth client ID and secret empty, because Claude registers itself via DCR.Click Connect. You'll be sent to GitHub. Approve, and you land back in Claude.
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
Add
<github_id>:<user_id>toALLOWED_GITHUB_USERSin/etc/garmin-mcp/envand restart.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 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/firehas 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 untilgarmin-mcp-loginwrites new tokens. A file lock allows one poller per data dir, andPOLL_MAX_FIRES_PER_DAYcaps 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:
Create the routine at claude.ai/code/routines, add an API trigger, and copy its ID and token.
Store the token, piped straight from the prompt into Secret Manager:
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=NoneAdd
POLL_USER_ID=holden,ROUTINE_ID=<id>,ROUTINE_TOKEN_SECRET_NAME=garmin-mcp-routine-tokenandPOLL_DRY_RUN=trueto/etc/garmin-mcp/env, runsudo systemctl restart garmin-mcp, and watchjournalctl -u garmin-mcp -f | grep poll. A dry run logs the text it would send and saves nothing.When the output looks right, set
POLL_DRY_RUN=falseand 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
.localredirect 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: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/callbackas a redirect URI on the Google OAuth client, then createdata/rabbit-dev/phone.env(mode 600):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:holdenRun
scripts/dev-rabbit.sh phone. The frontend rebuilds on save; refresh the phone.
On the VM:
./infra/setup.shcreatesgarmin-mcp-google-client-secretandgarmin-mcp-anthropic-api-key(empty),garmin-mcp-vapid-private-keyandgarmin-mcp-rabbit-fernet-key(generated) and grants the VM access. Store the Claude API key (from the Console; set a spend limit there too):pbpaste | tr -d '\n' | gcloud secrets versions add garmin-mcp-anthropic-api-key --project <PROJECT> --data-file=-Google Cloud console → Google Auth Platform: External, scopes
openid,emailandprofileonly. 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 URIhttps://<domain>/app/auth/google/callback. Store the secret:read -rs S; printf '%s' "$S" | gcloud secrets versions add garmin-mcp-google-client-secret --project <PROJECT> --data-file=-./deploy.sh --rabbit --bootstrap: creates the user, dirs,/etc/rabbit/env(frominfra/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.Fill in
/etc/rabbit/envand restart Rabbit (or edit the file on the VM by hand):scripts/rabbit-prod-env.sh <google_client_id> "you@gmail.com:you,friend@gmail.com:friend" mailto:you@example.comLater updates:
./deploy.sh --rabbit(buildsweb/locally with bun; restarts only Rabbit)../deploy.sh --rabbit --rollbackrolls 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.
License
MIT. Based on anlinguist/garmin-mcp by Andrew Nelson. The TrainingPeaks endpoints and structured-workout format were worked out with reference to JamsusMaximus/TrainingPeaks-MCP (MIT). Garmin, TrainingPeaks and Ironman are trademarks of their owners; this project isn't affiliated with any of them.
Related MCP Connectors
Garmin data in Claude & ChatGPT via the Garmin Health API. OAuth sign-in, no password sharing.
Pace is a remote MCP server that exposes wearable and fitness data to Claude via the Model Context Protocol. It connects to Garmin, Oura, Whoop, Polar, Fitbit and 20+ devices and provides 15 tools for querying sleep, activity, recovery, and training data. Hosted on Google Cloud Run, OAuth 2.1 authentication, Streamable HTTP transport. Instructions: First you need to create an account at: https://pacetraining.co and connect your wearables. After that you can connect the remote Server via Custom Connector in Claude and OAuth 2.1 Flow startet.
Garmin data in Claude: 135 tools — activities, sleep, HRV, training, workouts. Free, open source.
Connect Claude to your Intervals.icu watch data for fitness, workout review, and plan writing.
Related MCP Servers
- AlicenseBqualityAmaintenanceConnects to Garmin Connect and exposes your fitness and health data (activities, sleep, heart rate, steps, body composition) to Claude and other MCP-compatible clients.1481,339MIT
- AlicenseAqualityBmaintenanceEnables Claude to access and query your Garmin Connect data, including sleep, activities, training load, and health metrics, through a set of read-only MCP tools.28214 PyPI3MIT
- AlicenseBqualityBmaintenanceEnables users to analyze their own Garmin Connect data—activities, sleep, HRV, Body Battery, training readiness—directly inside Claude Desktop.132MIT
- AlicenseAqualityBmaintenanceConnects Claude to your Garmin Connect account to read activities, health metrics, and sleep data, and to create and schedule structured workouts directly onto your watch, all locally without exposing credentials.83MIT