Garmin Connect MCP 服务器

这是一个MCP服务器,将Claude连接到Garmin Connect,提供健康与健身数据访问、力量训练计划推送及个人举重日志管理,适用于需要自动化处理Garmin数据的场景。

  • by PepperoniRollz
  • TypeScript
  • 混合部署
  • 1 星标
  • 更新于
在 GitHub 查看 PepperoniRollz/garmin-connect-mcp

使用说明

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 date to "today" (log-lift, the Garmin daily tools) resolve the calendar day in LIFT_TIMEZONE (falling back to TZ, 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)

  1. 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.
  2. Owner password hash (the plaintext never appears in any config):
    npm run hash-password
    
  3. Configure: copy the repo to the server, then
    cp .env.deploy.example .env && chmod 600 .env
    
    Fill in GARMIN_USERNAME, GARMIN_PASSWORD, GARMIN_MCP_PUBLIC_URL (e.g. https://garmin-mcp.example.com/mcp — path must be /mcp), and SERVER_OWNER_PASSWORD_HASH (single-quoted — bcrypt hashes contain $, which Docker Compose otherwise interpolates).
  4. Run:
    docker compose up -d --build
    
    The container publishes to 127.0.0.1:8081 only.
  5. 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.sh automates this (plus compose-plugin install) with a validate-before-reload safety path; review it first.
  6. 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-connect client. 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.json from your TOKEN_CACHE_DIR into 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.

License

MIT