使用说明
garmin-connect-mcp
An MCP server that connects Claude to your Garmin Connect data — steps, heart rate, sleep, activities, workouts, hydration, weight, and golf summaries — plus a personal lift log and the ability to push structured strength workouts to Garmin Connect (and from there to the watch). 26 tools.
Runs in two modes from one codebase:
| Mode | Transport | Auth | Use case |
|---|---|---|---|
| stdio (default) | stdin/stdout subprocess | none (local) | Claude Code / Claude Desktop on your machine |
| http | Streamable HTTP | built-in OAuth 2.1 | Remote custom connector for claude.ai web, desktop, and mobile |
Tools
Date inputs are optional YYYY-MM-DD strings defaulting to today (or last
night for sleep tools).
| Tool | Returns | Units / payload notes |
|---|---|---|
get-user-profile |
Garmin profile info | |
get-user-settings |
Units, display preferences | |
get-activities |
Recent activities (pagination, type filter) | |
get-activity-details |
One activity by ID | |
get-strength-sets |
Per-set strength breakdown of a lifting session for post-workout review: each exercise with its sets (reps, weightLb, restSec), conditioning context (duration, avg/max HR, total rest, calories), and a human-readable summary. Pass activityId, date (that day's strength session), or neither (newest) |
Reps/weight are validated on the watch during each set, so this is the primary lifting record. Weight is integer grams → lb (raw weightGrams retained). Auto-detected exercises below 90% confidence (or UNKNOWN) are surfaced in lowConfidenceFlags to verify. Read-only — pairs with confirm-strength-session to commit |
confirm-strength-session |
Write. Imports a reviewed strength session (activityId) into the lift log so it counts toward progression. One logged session per exercise (source: garmin), keyed by activity id |
Idempotent — re-confirming the same activity never double-imports. Garmin supersedes a manual entry on the same date+lift (reported as supersededManual). Garmin keys map back to lift shorthands (BARBELL_ROW → barbell-row); unmapped keys import under a normalized name and are flagged. Review with get-strength-sets first |
count-activities |
Activity counts by type | |
get-training-status |
Day's training-status summary: training status + feedback + fitness trend, acute/chronic load with the ACWR (acute:chronic workload ratio) over/under-training signal, VO2max (precise + rounded) + fitness age, and monthly load balance (aerobic low/high/anaerobic vs target ranges) | Forerunner 965-class metric — available:false for pre-upgrade dates / unsupported devices. Reports a "warming up" note instead of a status when the device lacks recent history. Read live from /metrics-service/metrics/trainingstatus/aggregated/{date}; default date respects LIFT_TIMEZONE |
get-daily-summary |
Total / active / resting (BMR) calories burned, calories consumed + goal + remaining, steps, distance, intensity minutes, HR range, stress, Body Battery | Calories are kilocalories (dietary Calories); totalKilocalories = activeKilocalories + bmrKilocalories. remainingKilocalories = netCalorieGoal − consumed + activeKilocalories (activity is credited back). Macro grams are not in this payload, and consumedKilocalories can lag/disagree with the food log — for intake (calories and macros) prefer get-nutrition. Burn/steps/stress/Body Battery keep accruing until the day ends and the device syncs |
get-nutrition |
The day's logged intake from Garmin's native food log: consumed {calories, carbs, fat, protein} grams, daily goals (base + activity-adjusted), loggedItemCount, and a per-meal breakdown (macros, nested micros, each logged food with name/brand/per-serving macros) |
Authoritative intake source — preferred over get-daily-summary.consumedKilocalories. Macros in grams. hasMacros is true on itemized-food days and false on calorie-only days, where macro grams are null (not 0) so missing ≠ "ate 0g". Read live from /nutrition-service/food/logs/{date} (Connect+ feature); default date respects LIFT_TIMEZONE |
get-steps |
Step count for a date | Can lag get-daily-summary.totalSteps until the next device sync |
get-heart-rate |
Heart rate series + summary for a date | |
get-sleep |
Condensed sleep: duration, deep/light/REM/awake stages, score, overnight HRV, RHR, Body Battery change | All durations in seconds. date = the morning the sleep ended |
get-sleep-data |
Full raw sleep payload (movement, respiration, HR/Body Battery series) | Large payload; prefer get-sleep unless you need the series data |
get-sleep-duration |
Sleep hours + minutes | |
get-daily-weight |
Weight entries for a date | Weights are in grams (e.g. 81000 = 81 kg / ~178.6 lb) |
get-daily-hydration |
Water intake for a date | Ounces |
get-workouts |
Saved workout plans | |
get-golf-summary |
Golf round summaries | |
log-lift |
Write. Logs a lifting session (lift, sets[] of {weight, reps}, optional date/note) and returns a double-progression assessment |
The manual fallback for watchless sessions and corrections — Garmin (via confirm-strength-session) is the primary source. Stored in a separate lift DB (LIFT_DB_PATH), never in the auth DB. All sets at the rep target → suggests adding weight (lower-body lifts get the larger jump); otherwise hold and beat the logbook |
get-lift-history |
Logged sessions newest-first, each with top-set weight, total reps, and source (manual/garmin) (optional lift filter, limit) |
Unified log: imported Garmin sessions alongside manual ones |
get-lift-progress |
Weight progression over time for one lift (each point tagged source), plus current working weight and whether you're due to add weight |
Unified log: imported Garmin sessions are primary, manual entries fill watchless days |
update-lift |
Write. Corrects a logged session in place by id — change sets, date, note, or lift; weight overwrites the load on every existing set (reps kept) |
Get the id from log-lift/get-lift-history |
delete-lift |
Write. Removes a logged session by id |
|
create-workout |
Write (Garmin). Creates a structured strength workout in Garmin Connect from name + exercises[] of {exercise, sets, reps, targetWeight?, restSeconds?}; optional scheduleDate puts it on the calendar |
Syncs to the watch under Training > Workouts. Each exercise is either exercise (one of ~50 configured lift shorthands covering barbell, dumbbell, cable, and machine work — src/constants.ts LIFT_EXERCISES) or a raw {category, exerciseName} Garmin-taxonomy pair (validated against Exercises.json when reachable, accepted with a warning otherwise). targetWeight is in your Garmin display unit (lb/kg auto-detected from settings); restSeconds makes timed rests, omitted = lap-button rest |
delete-workout |
Write (Garmin). Deletes a workout from Garmin Connect by workoutId |
workoutId from get-workouts/create-workout |
Default date and timezone. Tools that default
dateto "today" (log-lift, the Garmin daily tools) resolve the calendar day inLIFT_TIMEZONE(falling back toTZ, then the runtime's zone). Set this to your IANA zone (e.g.America/New_York) so a late-night log lands on the correct local day rather than UTC.
The lift log is user-written personal data, stored in its own SQLite database (
LIFT_DB_PATH), kept entirely separate from Garmin-sourced data and from the OAuth auth database. Your real lift history lives only on your server's volume and is never committed to this repo.
Quickstart: stdio mode (Claude Code)
git clone https://github.com/PepperoniRollz/garmin-connect-mcp.git
cd garmin-connect-mcp
npm ci && npm run build
Store your Garmin credentials in the OS credential store (macOS shown; Windows Credential Manager and Linux libsecret are also supported — the server prints per-platform instructions if credentials are missing):
security add-generic-password -s garmin-connect-mcp -a username -w 'you@example.com'
security add-generic-password -s garmin-connect-mcp -a password -w 'your-garmin-password'
Then register it with Claude Code:
claude mcp add garmin -- node /path/to/garmin-connect-mcp/dist/index.js
GARMIN_USERNAME / GARMIN_PASSWORD environment variables take precedence
over the credential store when both are set.
Remote connector mode (claude.ai web / desktop / mobile)
In HTTP mode the server hosts its own OAuth 2.1 authorization server (PKCE S256 only, dynamic client registration, rotating refresh tokens) so claude.ai can connect securely. You deploy it behind a TLS-terminating reverse proxy; only you can authorize access, via a single owner password.
┌─────────────────────────────────────────┐
│ Your server │
Claude (Anthropic │ ┌────────┐ ┌────────────────────┐ │
cloud) ── HTTPS ────▶│ │ Caddy │─────▶│ garmin-connect-mcp │ │
│ │ :443 │ │ http mode :8081 │ │
│ └────────┘ │ (localhost only) │ │
│ auto-TLS └─────────┬──────────┘ │
└────────────────────────────┼────────────┘
▼
Garmin Connect API
Deploy (Docker Compose + Caddy)
- DNS: point a subdomain (e.g.
garmin-mcp.example.com) at your server. If you use Cloudflare, "DNS only" (gray cloud) is the verified configuration; the proxied mode is untested with long-lived SSE streams. - Owner password hash (the plaintext never appears in any config):
npm run hash-password - Configure: copy the repo to the server, thenFill in
cp .env.deploy.example .env && chmod 600 .envGARMIN_USERNAME,GARMIN_PASSWORD,GARMIN_MCP_PUBLIC_URL(e.g.https://garmin-mcp.example.com/mcp— path must be/mcp), andSERVER_OWNER_PASSWORD_HASH(single-quoted — bcrypt hashes contain$, which Docker Compose otherwise interpolates). - Run:The container publishes to
docker compose up -d --build127.0.0.1:8081only. - Caddy site block (Caddy obtains the Let's Encrypt certificate
automatically):
garmin-mcp.example.com { reverse_proxy localhost:8081 encode gzip }scripts/phase4-root-setup.shautomates this (plus compose-plugin install) with a validate-before-reload safety path; review it first. - Connect: claude.ai → Settings → Connectors → Add custom connector →
https://garmin-mcp.example.com/mcp. Claude discovers the OAuth metadata, registers itself, and sends you to the login page; enter the owner password to approve. The same connector works on Claude mobile.
Verify a deployment
curl https://garmin-mcp.example.com/healthz # → {"status":"ok"}
curl -X POST https://garmin-mcp.example.com/mcp # → 401 + WWW-Authenticate
OAUTH_TEST_PASSWORD=... npx tsx scripts/oauth-flow-test.ts https://garmin-mcp.example.com
Environment variables
| Variable | Mode | Required | Default | Purpose |
|---|---|---|---|---|
GARMIN_USERNAME |
both | http: yes | — | Garmin account email (stdio falls back to the OS credential store) |
GARMIN_PASSWORD |
both | http: yes | — | Garmin account password |
TRANSPORT_MODE |
both | no | stdio |
stdio or http (--transport flag wins) |
PORT |
http | no | 8080 |
Listen port |
BIND_HOST |
http | no | 127.0.0.1 |
Listen interface; 0.0.0.0 inside containers |
GARMIN_MCP_PUBLIC_URL |
http | yes | — | Public MCP endpoint URL; OAuth resource equals it exactly; path must be /mcp |
SERVER_OWNER_PASSWORD_HASH |
http | yes | — | bcrypt hash from npm run hash-password |
AUTH_DB_PATH |
http | no | ~/.garmin-mcp-auth.db |
SQLite file for OAuth clients/codes/tokens |
TOKEN_CACHE_DIR |
both | no | ~/.garmin-mcp-tokens |
Garmin OAuth token cache |
LIFT_DB_PATH |
both | no | ~/.garmin-mcp-lifts.db |
SQLite file for the personal lift log (separate from auth) |
LIFT_TIMEZONE |
both | no | TZ, else runtime zone |
IANA zone for the default "today" (e.g. America/New_York) |
TRUSTED_PROXY |
http | no | loopback |
Express trust-proxy (IP/CIDR/list); set to the Docker bridge gateway in compose |
LOG_LEVEL |
both | no | info |
debug/info/warn/error; structured JSON on stderr |
The server never reads .env files itself — provide real environment
variables (Docker Compose env_file:, systemd EnvironmentFile=, shell).
Security notes
- Unofficial API disclaimer: this project uses the unofficial
garmin-connectclient. Garmin does not sanction it; it can break without notice, and aggressive use could affect your Garmin account. Use at your own risk. - Single-user model: one deployment serves exactly one Garmin account. OAuth protects access to the server; whoever completes the owner login sees that account's data. There is no multi-tenancy.
- Credential handling: Garmin credentials live in env vars (HTTP mode) or the OS credential store (stdio). The owner password exists only as a bcrypt hash. OAuth tokens and codes are stored as SHA-256 hashes in SQLite and survive restarts; refresh tokens rotate on use.
- Never expose HTTP mode without auth. See SECURITY.md.
- Datacenter-IP caveat: Garmin throttles (429) fresh logins from cloud
provider IPs. Seed the deployed token cache with tokens minted on a
residential connection: run the server locally once, then copy
oauth1_token.json/oauth2_token.jsonfrom yourTOKEN_CACHE_DIRinto the server's token volume. Cached tokens refresh without re-triggering the throttle.
Development
npm run dev # run from source (tsx)
npm run build # compile to dist/
npm run typecheck # tsc --noEmit (src + scripts)
npm run lint # gts (Google TypeScript style)
npm test # e2e: OAuth flow + session lifecycle, no Garmin account needed
Architecture and roadmap: docs/SPEC.md.