Skip to main content
Glama
Matt-y-Matt

Garmin Health MCP Server

by Matt-y-Matt

Garmin Health MCP Server

A local, private MCP server that gives Claude Desktop access to your Garmin Connect history. Nothing leaves your machine: a sync script pulls your data into a local SQLite file, and the MCP server serves that file read-only.

Garmin Connect ──garth──▶ sync_garmin.py ──▶ garmin_health.db ──▶ garmin_mcp_server.py ──▶ Claude Desktop
                          (you run this)      (local SQLite)       (read-only, offline)

Separating ingestion from serving means the MCP server never holds your credentials, never makes a network call, and cannot be talked into hammering Garmin's API from inside a conversation.

Files

File

Purpose

sync_garmin.py

Phase 1 -- authenticate with Garmin, fetch every dataset, write to SQLite

garmin_mcp_server.py

Phase 2 -- expose the database to Claude Desktop as MCP tools

garmin_db.py

Shared schema, connection handling and queries used by both

tests/test_offline.py

End-to-end verification with synthetic data (no Garmin account needed)

garmin_db.py exists so the writer and the reader cannot drift apart on schema.

Related MCP server: Garmin Connect MCP Server

Install

git clone <this repo> && cd Garmin-MCP
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

Requires Python 3.10+ (the code uses X | None type syntax).

Phase 1 -- sync your data

Do a dry run first. It authenticates and fetches, but writes nothing:

python sync_garmin.py --login --dry-run

You will be prompted for your Garmin email, password and (if enabled) an MFA code. On success, OAuth tokens are saved to ~/.garth with 0600 permissions, and later runs reuse them -- no password needed:

python sync_garmin.py                       # 10 activities + 7 days
python sync_garmin.py --activities 50 --days 30
python sync_garmin.py --no-details          # skip splits/HR zones (much cheaper)
python sync_garmin.py --db ~/data/garmin.db

One run fetches activities, sleep, body battery and readiness, training load, VO2max/endurance/hill scores, personal records, body composition and gear. Splits, heart-rate zones and training effect need three extra requests per activity, so they are fetched once per activity, only for activities not yet enriched, capped at --detail-limit (default 25) per run. A first sync of a long history therefore fills in details over several runs rather than issuing hundreds of requests at once.

To avoid typing credentials at all, export them first:

export GARMIN_EMAIL="you@example.com"
export GARMIN_PASSWORD='...'                # leading space keeps it out of shell history in bash/zsh
python sync_garmin.py --login

Running it safely

  • Every write is an upsert keyed on activity ID or calendar date, so re-running the same window updates rows in place. It will never duplicate or delete.

  • --dry-run prints what would be written and touches nothing.

  • The token directory holds live credentials for your Garmin account. Treat it like an SSH key; .gitignore already excludes it and the database.

  • Be gentle with --days. Several metrics cost one request per day, and Garmin will rate-limit or temporarily lock an account that hammers the API. A daily sync of the default window is well within normal app behaviour; a first-time backfill of a year is not — do it in chunks, or start with --no-details.

  • Schema changes are applied in place. Upgrading this project adds the new columns and tables to your existing database without losing rows, so you never need to delete and re-sync.

  • If one metric fails (older watches have no Training Readiness or HRV), that dataset is logged as an error and the rest of the sync still completes.

Schedule it daily if you like -- but run --login interactively once first so tokens exist, since MFA cannot be answered from cron:

0 7 * * *  cd /path/to/Garmin-MCP && .venv/bin/python sync_garmin.py >> sync.log 2>&1

Phase 2 -- the MCP server

Eleven tools are exposed.

Workouts

Tool

Signature

Returns

get_recent_activities

limit: int = 5 (max 50)

Type, start time, duration, distance, pace, avg/max HR, calories, ascent, training effect

search_activities

activity_type, start_date, end_date, min_distance_km, limit

The same, filtered -- "my runs in July", "every ride over 50 km"

get_activity_details

activity_id: int

Per-lap splits, time in each HR zone, power, cadence, stride, SWOLF

compare_activities

activity_ids: list[int] (2-5)

Aligned side-by-side table of the key metrics

Recovery, load and fitness

Tool

Signature

Returns

get_sleep_and_readiness

days: int = 7

Sleep score, deep/light/REM/awake, sleep HR, SpO2, body battery, training readiness, resting HR, HRV

get_training_load

days: int = 14

Acute (fatigue) and chronic (fitness) load, acute:chronic ratio, training status, weekly load vs optimal range

get_fitness_scores

days: int = 30

VO2max, fitness age, endurance score, hill score, and the change across the window

get_weekly_summary

weeks: int = 4

Volume, load and elevation next to the week's average sleep, resting HR and HRV

Records, body and kit

Tool

Signature

Returns

get_personal_records

--

Lifetime PRs with the activity that set each

get_weight_history

days: int = 30

Weight, BMI, body fat, muscle mass, and the change across the window

get_gear

--

Shoe/bike mileage, activity count, and life left against the wear limit

Every tool prepends the last sync timestamp so Claude knows how fresh the data is, and returns a clear "run sync_garmin.py first" message when the database is missing or empty. Metrics your watch does not record show as n/a rather than being guessed at.

On training load

get_training_load reports Garmin's own acute and chronic load rather than a reimplemented TSS model. They are the direct equivalents of what other platforms label CTL/ATL/TSB:

Garmin

Elsewhere

Meaning

Acute load (7-day)

ATL

Fatigue

Chronic load (28-day)

CTL

Fitness

Acute:chronic ratio

TSB / form

Balance between the two, and Garmin's injury-risk signal

Garmin only recomputes these when the watch syncs, so consecutive days can repeat and unsynced days are absent. The sync stores what Garmin returns and never interpolates.

Verify it runs before wiring it up:

python garmin_mcp_server.py     # sits waiting on stdio; Ctrl-C to exit

Phase 3 -- register with Claude Desktop

Edit claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

  • Windows: %APPDATA%\Claude\claude_desktop_config.json

  • Linux: ~/.config/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "garmin-health": {
      "command": "/path/to/your/project/.venv/bin/python",
      "args": ["/path/to/your/garmin_mcp_server.py"],
      "env": {
        "GARMIN_DB_PATH": "/path/to/your/project/garmin_health.db",
        "GARMIN_UNITS": "metric"
      }
    }
  }
}

Use absolute paths throughout, and point command at the virtualenv's Python (not a bare python) so the mcp and garth packages resolve. Restart Claude Desktop completely, then look for the tools under the 🔨 icon.

Ask things like "How did I sleep this week?", "Am I overtraining?", "Is my VO2max trending up?", "Compare my last three long runs", "How many km are on my shoes?", or "Given my load and recovery, what should I do today?"

Configuration

Variable

Default

Meaning

GARMIN_DB_PATH

garmin_health.db beside the scripts

SQLite location

GARMIN_TOKEN_DIR

~/.garth

Saved OAuth tokens

GARMIN_EMAIL / GARMIN_PASSWORD

unset

Non-interactive login

GARMIN_UNITS

metric

imperial switches to miles

GARMIN_MCP_LOG_LEVEL

INFO

Server log verbosity (stderr only)

GARMIN_DB_PATH defaults to a path beside the script rather than the working directory, because Claude Desktop launches servers with an unpredictable CWD.

Verifying without a Garmin account

python tests/test_offline.py

This pushes realistic Garmin JSON through the real parsers, schema and tools, then launches the server over stdio and calls all eleven tools the way Claude Desktop does. It also checks that re-syncing does not duplicate rows, that a failed endpoint does not blank out previously stored values, and that an older database migrates in place without losing data. Verified against mcp 1.29.0 and 2.0.0.

Notes and caveats

  • garth is deprecated. It prints a notice on import and is no longer actively maintained, though it still works against the current Connect API. Auth is isolated in authenticate() and fetching in the fetch_* functions, so swapping the client later means touching only sync_garmin.py.

  • FastMCP moved in SDK 2.0. mcp.server.fastmcp.FastMCP became mcp.server.mcpserver.MCPServer. The decorator and run() APIs are identical, so the server tries the 1.x path first and falls back to 2.x.

  • Sleep is parsed from raw JSON, not garth's model. In garth 0.8.0 the pulse-ox fields never populate: its camel_to_snake_dict converts averageSpO2HRSleep to average_sp_o_2_hr_sleep, but DailySleepDTO declares average_sp_o2_hr_sleep, so the key silently fails to match and the value is dropped. Average sleep HR and SpO2 would always be NULL. The sync script calls the same endpoint through garth.connectapi and maps the fields explicitly. Everything else uses garth's typed models.

  • Fitness scores are read from the raw endpoints too. garth.GarminScoresData requires both the hill-score and endurance-score endpoints to return data and pops fixed keys from each, so a watch reporting VO2max but no hill score yields nothing at all. The sync fetches the three independently instead.

  • Not every metric exists on every watch. Training status, readiness, HRV, pulse-ox, hill and endurance scores are device-dependent; weight needs a connected scale and gear must be registered in Garmin Connect. Missing values are shown as n/a, never guessed.

  • Personal-record labels are best-effort. Garmin identifies records by a numeric type ID. The newer prTypeLabelKey string is used when present; otherwise a documented fallback table names the common running, cycling and step records. An unrecognised ID is shown as Record type N rather than mislabelled.

  • Deliberately not included: device inventory, women's health, and workout upload. The first two add little to a coaching conversation and the third writes to your Garmin account, which this project avoids entirely — the sync only ever reads.

  • This is not medical advice. It is your own data, summarised.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Garmin data in Claude: 135 tools — activities, sleep, HRV, training, workouts. Free, open source.

  • Garmin data in Claude & ChatGPT via the Garmin Health API. OAuth sign-in, no password sharing.

  • MCP server for Withings health data — sleep, activity, heart, and body metrics.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Matt-y-Matt/Garmin-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server