MCP server by cheng315ncu
Garmin MCP Server (Rust)
A Model Context Protocol (MCP) server that connects Claude and other MCP clients to Garmin Connect, exposing your fitness and health data through 77 tools across all major Garmin Connect features.
Written in Rust for a single-binary deployment with no runtime dependencies.
Why Rust?
| | Python version | This (Rust) |
|---|---|---|
| Deployment | Requires Python 3.12 + uv/pip | Single static binary |
| Startup | ~3–5 s (import + venv) | ~3–5 s (OAuth only) |
| Session safety | GIL + race conditions under async | Mutex / RwLock at the type level |
| Duplicate queries | Each tool independently calls Garmin | moka TTL cache + singleflight coalesces repeats |
| Rate limiting | None — burst traffic can trigger Garmin lockout | governor token-bucket, shared across all traffic |
| Research output | JSON only | JSON (default) + CSV (single-day & multi-day ranges) + EDF-ready trait |
| Memory | ~50 MB | ~5 MB |
| Binary size | N/A | ~10 MB (release) |
The session-sharing design is where Rust's concurrency model matters most: all GET requests are serialised through a single Mutex<GarminClient> (Garmin rate-limits per session token), while concurrent POST/DELETE operations share a RwLock<BearerToken> and a single pooled reqwest::Client — guaranteeing you never accidentally create a second OAuth session.
On top of that, a moka async cache (60 s TTL) sits in front of every GET: cache hits return immediately without touching the Mutex or the network, and concurrent callers for the same key share one in-flight fetch (singleflight). A governor rate limiter (60 req/min, configurable) gates both read and write paths so sustained LLM usage stays within Garmin's undocumented limits.
Tool Coverage
77 tools across 12 modules:
@startmindmap
<style>
mindmapDiagram {
node { FontSize 11; FontName "Segoe UI"; }
.health { BackgroundColor #a7f3d0; LineColor #065f46; FontColor #065f46; }
.activity { BackgroundColor #bfdbfe; LineColor #1e40af; FontColor #1e40af; }
.training { BackgroundColor #ddd6fe; LineColor #5b21b6; FontColor #5b21b6; }
.write { BackgroundColor #fed7aa; LineColor #92400e; FontColor #92400e; }
.device { BackgroundColor #e9d5ff; LineColor #6b21a8; FontColor #6b21a8; }
.research { BackgroundColor #fef9c3; LineColor #854d0e; FontColor #713f12; }
.neutral { BackgroundColor #f1f5f9; LineColor #64748b; FontColor #334155; }
}
</style>
*[#1e3a5f] **Garmin MCP**\n**77 Tools**
**[#1d4ed8] Activities (14) <<activity>>
***_ by-date · fordate
***_ recent · count · types
***_ splits · typed-splits · summaries
***_ weather · HR zones · exercise sets
***_ gear · training effect
**[#7c3aed] Training (3) <<training>>
***_ training status
***_ weekly progress
***_ race predictions
**[#7c3aed] Workouts (5) <<training>>
***_ list · get · scheduled
***_ delete · schedule
**[#854d0e] Research (4) <<research>>
***_ daily stats range (20 metrics)
***_ sleep range (16 metrics)
***_ HRV range (9 metrics)
***_ weekly summary (mean/std/min/max)
left side
**[#065f46] Health & Wellness (21) <<health>>
***_ stats · steps · floors
***_ sleep · heart rate · RHR
***_ stress · body battery & events
***_ HRV · SpO₂ · respiration
***_ training readiness · fitness age
***_ endurance · hill · lactate
***_ hydration · weigh-ins · blood pressure
**[#92400e] Write / Data (3) <<write>>
***_ log hydration
***_ record blood pressure
***_ body composition
**[#6b21a8] Devices (6) <<device>>
***_ list · last used · primary
***_ settings · solar · alarms
**[#475569] User Profile (4) <<neutral>>
***_ profile · settings
***_ full name · unit system
**[#475569] Gear (3) <<neutral>>
***_ list · add · remove
**[#475569] Challenges (8) <<neutral>>
***_ badges earned / available
***_ badge challenges · ad-hoc
***_ virtual · goals · PRs
**[#475569] Nutrition (3) <<neutral>>
***_ food log · settings · custom foods
**[#475569] Women's Health (3) <<neutral>>
***_ menstrual day · calendar
***_ pregnancy summary
@endmindmap
| Module | Tools | Highlights | |--------|------:|---| | Activities | 14 | by-date, splits, typed-splits, weather, HR zones, exercise sets, training effect, gear | | Health & Wellness | 21 | stats, sleep, HR, stress, body battery, HRV, SpO2, floors, respiration, fitness age, hydration | | Training & Performance | 3 | training status, weekly progress, race predictions | | Workouts | 5 | list, get, scheduled, delete, schedule | | Research / Longitudinal | 4 | multi-day stats/sleep/HRV datasets (up to 366 days); ISO-week statistical summaries | | Challenges & Badges | 8 | earned badges, badge challenges, ad-hoc challenges, goals, personal records | | Devices | 6 | list, last used, settings, primary, solar data, alarms | | Gear | 3 | list, add to activity, remove from activity | | User Profile | 4 | profile, settings, full name, unit system | | Women's Health | 3 | menstrual day/calendar, pregnancy summary | | Nutrition | 3 | food log, settings, custom foods | | Data Management | 3 | log hydration, record blood pressure, record body composition |
Intentionally omitted
get_activity_details— returns 50–500 KB GPS track data; useget_activityfor summariesdelete_activity— destructive, irreversible
Research Output Formats
Single-day clinical tools
Ten tools accept an optional format parameter for per-day queries:
| format | shape | best for |
|--------|-------|---------|
| "json" | pretty-printed object (default) | LLM chat, quick inspection |
| "csv" | header + row(s) | statistical batch processing; cat day1.csv day2.csv \| sort |
| Tool | CSV shape |
|------|-----------|
| get_stats | 1 summary row (28 fields) |
| get_sleep_summary | 1 summary row (stages, SpO2, respiration scores) |
| get_daily_heart_rate | 1 summary row (resting / min / max HR + sample count) |
| get_stress_summary | 1 summary row (avg/max stress + sample counts) |
| get_body_battery_summary | timestamp_ms, body_battery — one row per event |
| get_hrv_data | reading_time_gmt, hrv_value — one row per 5-min reading |
| get_respiration_data | 1 summary row (waking / sleep BPM) |
| get_spo2_data | 1 summary row (avg / lowest / sleep SpO2) |
| get_blood_pressure | one row per measurement (timestamp, systolic, diastolic, pulse) |
| get_daily_weigh_ins | one row per weigh-in (date, weight, BMI, body fat, …) |
Longitudinal research tools (up to 366 days per call)
Four dedicated research tools return multi-day datasets in a single call — designed for pandas, R, or any time-series pipeline:
| Tool | JSON | CSV | Columns |
|------|------|-----|---------|
| get_daily_stats_range | array of daily objects | header + 1 row per day | 20 (steps, calories, HR, stress, body battery, SpO2, respiration …) |
| get_sleep_range | array of daily objects | header + 1 row per day | 16 (total/deep/light/REM/awake seconds, SpO2, respiration, stress, awake count …) |
| get_hrv_range | array of daily objects | header + 1 row per day | 9 (weekly avg, last night, 5-min high/low, baseline, status, feedback) |
| get_weekly_summary | array of weekly objects | — (JSON only) | week, week_start, week_end, days_with_data + mean/std/min/max × 12 metrics |
Days with no data appear as date-only rows (the time series is never truncated).
// 90-day health panel — paste into pandas
{ "tool": "get_daily_stats_range",
"arguments": { "start_date": "2026-01-26", "end_date": "2026-04-25", "format": "csv" } }
// 30-day sleep trends
{ "tool": "get_sleep_range",
"arguments": { "start_date": "2026-03-26", "end_date": "2026-04-25", "format": "json" } }
// HRV autonomic trends
{ "tool": "get_hrv_range",
"arguments": { "start_date": "2026-03-26", "end_date": "2026-04-25", "format": "csv" } }
// Weekly training load summary (ISO weeks)
{ "tool": "get_weekly_summary",
"arguments": { "start_date": "2026-01-01", "end_date": "2026-04-25" } }
EDF (European Data Format for biosignals) is designed but not yet shipped — it needs a binary output policy decision (temp-file path vs base64) and an EDF crate evaluation. The ClinicalExport trait in tools/output.rs is where it will land.
Requirements
- Rust toolchain (
rustup+ stable) — for building from source - A Garmin Connect account
GARMIN_EMAILandGARMIN_PASSWORDenvironment variables
MFA note:
garmin_client 0.2does not support interactive MFA prompts. If your account has MFA enabled, you may need to temporarily disable it or use an app-password equivalent. This is a known upstream limitation tracked in Phase 5.
Installation
Build from source
git clone <this-repo>
cd garmin_mcp_rust
cargo build --release
# Binary at: target/release/garmin-mcp
Install to PATH
cargo install --path .
# Binary installed to ~/.cargo/bin/garmin-mcp
Configuration
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"garmin": {
"command": "/path/to/garmin-mcp",
"env": {
"GARMIN_EMAIL": "you@example.com",
"GARMIN_PASSWORD": "your_password"
}
}
}
}
Or use credential files (recommended — keeps secrets out of the config file):
echo "you@example.com" > ~/.garmin_email
echo "your_password" > ~/.garmin_password
chmod 600 ~/.garmin_email ~/.garmin_password
{
"mcpServers": {
"garmin": {
"command": "/path/to/garmin-mcp",
"env": {
"GARMIN_EMAIL_FILE": "/Users/you/.garmin_email",
"GARMIN_PASSWORD_FILE": "/Users/you/.garmin_password"
}
}
}
}
Cursor / other MCP clients
Same JSON structure; consult your client's MCP server configuration docs for the config file location.
Display name override
Some health tools (stats, sleep, heart rate, RHR) require your Garmin display name. The server auto-detects it at startup, but you can override it explicitly:
"env": {
"GARMIN_EMAIL": "...",
"GARMIN_PASSWORD": "...",
"GARMIN_DISPLAY_NAME": "your_garmin_handle"
}
Quick-start: .env file
For local development and smoke-testing, create a .env file in the project root (already in .gitignore):
GARMIN_EMAIL=you@example.com
GARMIN_PASSWORD=your_password
# Optional — override the auto-detected Garmin handle:
# GARMIN_DISPLAY_NAME=your_handle
The binary loads .env at startup via dotenvy. Existing process environment variables always win, so GARMIN_EMAIL=x cargo run still overrides the file.
Environment Variables
| Variable | Required | Description |
|---|---|---|
| GARMIN_EMAIL | ✅ (or _FILE) | Garmin Connect email |
| GARMIN_EMAIL_FILE | ✅ (or direct) | Path to file containing email |
| GARMIN_PASSWORD | ✅ (or _FILE) | Garmin Connect password |
| GARMIN_PASSWORD_FILE | ✅ (or direct) | Path to file containing password |
| GARMIN_DISPLAY_NAME | optional | Override auto-detected display name |
Running
# With .env file in project root (recommended for local dev)
echo "GARMIN_EMAIL=you@example.com" >> .env
echo "GARMIN_PASSWORD=secret" >> .env
./target/release/garmin-mcp
# Inline env vars (override .env)
GARMIN_EMAIL=you@example.com GARMIN_PASSWORD=secret ./target/release/garmin-mcp
# With credential files (recommended for production)
GARMIN_EMAIL_FILE=~/.garmin_email GARMIN_PASSWORD_FILE=~/.garmin_password \
./target/release/garmin-mcp
MCP Inspector
GARMIN_EMAIL=... GARMIN_PASSWORD=... \
npx @modelcontextprotocol/inspector ./target/release/garmin-mcp
Usage Examples
Once connected in Claude, you can ask:
"Show me my last 5 activities"
"What was my sleep like on April 20th?"
"How's my body battery been this week?"
"Show me the HR zone breakdown for activity 12345678"
"What are my race time predictions?"
"List my upcoming scheduled workouts"
"What gear do I have registered?"
"Log 500ml of water for today"
For researchers:
- "Give me the last 30 days of sleep data as CSV so I can import it into pandas."
- "Show me a weekly summary of my training load over the past 3 months."
- "Export 90 days of HRV data for autonomic nervous system analysis."
Tool reference
All tools accept JSON arguments. Clinical tools also accept an optional "format" field ("json" or "csv").
// Activities
{ "tool": "get_recent_activities", "arguments": { "limit": 10 } }
{ "tool": "get_activities_by_date", "arguments": { "start_date": "2026-04-01", "end_date": "2026-04-25" } }
{ "tool": "get_activity_splits", "arguments": { "activity_id": "12345678" } }
{ "tool": "get_activity_hr_in_timezones", "arguments": { "activity_id": "12345678" } }
// Health — JSON (default)
{ "tool": "get_stats", "arguments": { "date": "2026-04-25" } }
{ "tool": "get_sleep_summary", "arguments": { "date": "2026-04-25" } }
{ "tool": "get_hrv_data", "arguments": { "date": "2026-04-25" } }
{ "tool": "get_blood_pressure", "arguments": { "start_date": "2026-04-01", "end_date": "2026-04-25" } }
{ "tool": "get_daily_steps", "arguments": { "start_date": "2026-04-18", "end_date": "2026-04-25" } }
// Health — CSV for batch / statistical use
{ "tool": "get_stats", "arguments": { "date": "2026-04-25", "format": "csv" } }
{ "tool": "get_hrv_data", "arguments": { "date": "2026-04-25", "format": "csv" } }
{ "tool": "get_body_battery_summary", "arguments": { "date": "2026-04-25", "format": "csv" } }
{ "tool": "get_blood_pressure", "arguments": { "start_date": "2026-01-01", "end_date": "2026-04-25", "format": "csv" } }
// Training
{ "tool": "get_training_status", "arguments": { "date": "2026-04-25" } }
{ "tool": "get_progress_summary_between_dates", "arguments": { "start_date": "2026-04-01", "end_date": "2026-04-25" } }
{ "tool": "get_race_predictions", "arguments": {} }
// Write operations
{ "tool": "add_hydration_data", "arguments": { "date": "2026-04-25", "value_in_ml": 500 } }
{ "tool": "set_blood_pressure", "arguments": { "date": "2026-04-25", "systolic": 120, "diastolic": 80, "pulse": 65 } }
{ "tool": "add_body_composition", "arguments": { "date": "2026-04-25", "weight_kg": 72.5 } }
{ "tool": "schedule_workout", "arguments": { "workout_id": "987654", "date": "2026-04-27" } }
// Longitudinal research — multi-day datasets
{ "tool": "get_daily_stats_range", "arguments": { "start_date": "2026-01-26", "end_date": "2026-04-25", "format": "csv" } }
{ "tool": "get_sleep_range", "arguments": { "start_date": "2026-03-26", "end_date": "2026-04-25", "format": "json" } }
{ "tool": "get_hrv_range", "arguments": { "start_date": "2026-03-26", "end_date": "2026-04-25", "format": "csv" } }
{ "tool": "get_weekly_summary", "arguments": { "start_date": "2026-01-01", "end_date": "2026-04-25" } }
Smoke Test
dev/smoke.sh runs a minimal MCP session via stdin/stdout against the real binary:
# Credentials from .env (simplest)
DATE=2026-04-24 bash dev/smoke.sh
# Or pass inline
DATE=2026-04-24 GARMIN_EMAIL=you@example.com GARMIN_PASSWORD=secret bash dev/smoke.sh
# Server stderr (auth progress, API logs) → /tmp/garmin-mcp.err
# MCP JSON-RPC responses → stdout
Architecture
src/
├── main.rs — startup: load .env, OAuth, stdio transport
├── auth.rs — OAuth login, display-name resolution, session file read
├── client.rs — GarminApiClient (cache + rate-limit + three-layer sync)
└── tools/
├── mod.rs — GarminMcpServer + #[tool_router] (all 77 tools registered here)
├── output.rs — ClinicalExport trait: FlatSummary / HrvPayload / TimeseriesArray / EventTable
├── activities.rs — 14 tools
├── health_wellness.rs — 21 tools (10 with format=csv)
├── research.rs — 4 longitudinal research tools (date-range datasets + weekly stats)
├── training.rs — 3 tools
├── workouts.rs — 5 tools
├── challenges.rs — 8 tools
├── devices.rs — 6 tools
├── gear.rs — 3 tools
├── user_profile.rs — 4 tools
├── womens_health.rs — 3 tools
├── nutrition.rs — 3 tools
└── data_management.rs — 3 tools (POST)
GET pipeline (client.rs)
🦀 Garmin MCP Rust · GET Request Pipeline
HR · stress · resp
5-min readings
body battery
date-range
reserved
Key: endpoint?k=v sorted
Value: Arc<Value>
Concurrent callers share
one in-flight fetch
Skips rate-limiter
No Mutex · no network
Configurable constant
one limiter for all
until_ready().await
per-session rate-limit
abuse and bans
api_request needs &mut self
One in-flight at a time
Exclusive write (refresh)
Double-checked locking
Shared across writes
Reuses TLS + TCP
Mutex-serialized · one at a time
Response cached for 60s
Uses RwLock bearer token
On success: cache.invalidate_all()
Write path (POST / PUT / DELETE) skips the cache and Mutex, goes through the rate limiter, then reqwest with the pooled Arc<RwLock<BearerToken>>. Successful writes call cache.invalidate_all() so the next GET sees fresh data.
Session layers
Arc<RwLock<BearerToken>> — shared token for write operations
multiple POST/DELETE can hold the read lock
concurrently; token refresh acquires the write
lock exclusively (double-checked locking).
reqwest::Client (struct field) — one HTTP client, shared across all writes;
reuses the TCP connection pool and TLS session.
Known Limitations
- No MFA support —
garmin_client 0.2does not expose an interactive MFA prompt. Planned for Phase 6 (upstream fix or local fork). - Token refresh —
garmin_clientdoes not rewrite.garmin_session.jsonafter an in-process token refresh. The refresh architecture is in place; once upstream is fixed it will activate automatically. OAuth tokens are valid for ~1 hour; restart the server if you hit a long session. - Write operations unverified — POST/DELETE tools (
data_management,gear write,workouts write) use correct endpoints but have not been end-to-end tested against a live account. - Non-public API — Garmin Connect has no official public API. Endpoints are derived from community reverse-engineering and may change without notice.
- Account-gated features — Tools for workout library, training readiness, nutrition logging, menstrual/pregnancy tracking, body battery events, and goals return a friendly "no data" message when the account or device does not support the feature.
isErroris alwaysfalse; the API signal (e.g.HTTP 404orNotAllowedException) is included in the message for diagnostics.
Known garmin_client 0.2.1 bugs (worked around)
- URL double-slash —
add_route()prepends/; endpoints with a leading slash produceconnectapi.garmin.com//path→ 404. Fixed:trim_start_matches('/')inapi_json(). - Token re-quoting —
retrieve_json_session()usesValue::to_string()on the cached token, wrapping it in extra quotes → 401. Fixed: delete.garmin_session.jsonon every startup (forces fresh OAuth); read the token with.as_str()to avoid re-quoting. - GET-only client —
api_request()only supports GET. Fixed: POST/DELETE usereqwestdirectly with the bearer token read from the session file.
Phase 6 (Planned)
- Token persistence — skip re-login when the cached token is still valid; store session in
~/.garminconnect_rust/session.json - MFA support — interactive MFA prompt via stderr on first run
- China region —
GARMIN_IS_CN=trueswitches toconnect.garmin.cn garmin-mcp-authbinary — standalone one-time auth helper (mirrors the Pythongarmin-mcp-authcommand)
Acknowledgements
This project is a Rust port of garmin_mcp by @Taxuspt.
The original Python implementation established the tool taxonomy, endpoint mapping, and field-curation design that this port follows. The intentionally-omitted endpoints, the display-name resolution strategy, and the overall MCP tool structure are all derived from that prior work.