Content
# garmin-mcp
Read-only MCP server exposing Garmin health and fitness data for AI agents.
## Tools
| Tool | Description |
|------|-------------|
| `get_user_id` | Get the authenticated Garmin user ID |
| `get_daily_summaries` | Get daily activity data (steps, calories, distance, active minutes) |
| `list_activities` | List workouts, runs, rides, and other activities |
| `get_activity_details` | Get detailed breakdown of a specific activity |
| `get_sleep_data` | Get sleep sessions with stages and quality metrics |
| `get_heart_rate_data` | Get heart rate and HRV data |
| `get_body_composition` | Get weight, BMI, body fat, and muscle mass |
| `get_stress_data` | Get stress levels and body battery |
| `get_respiration_data` | Get breathing rate data |
| `get_pulse_ox_data` | Get blood oxygen saturation (SpO2) readings |
All date-range tools default to a 7-day window (30 days for `list_activities` and `get_body_composition`). Internally they split into 24-hour chunks (Garmin's per-request cap) and fan out concurrently — see [Why chunking?](#why-chunking).
## Setup
See [SETUP.md](SETUP.md) for OAuth configuration. You need **two** credentials in your environment — see [Why two credentials?](#why-two-credentials) below.
```env
GARMIN_ACCESS_TOKEN=... # OAuth 2.0 PKCE Bearer (per user)
GARMIN_PULL_TOKEN=... # Partner Pull Token (per app, from developer portal)
```
## Run
```bash
# Local (stdio)
uv run python server.py
# Docker (HTTP)
docker compose up
```
## API Reference
- **Base URL**: `https://apis.garmin.com/wellness-api/rest`
- **Auth**: OAuth 2.0 Bearer **plus** Partner Pull Token (`?token=…`) on every data endpoint. See below.
- **Documentation**: <https://developer.garmin.com/gc-developer-program/health-api/>
- **Live Swagger**: developer portal → API Documentation → "wellness-api" definition
## Why two credentials?
Garmin's Wellness/Health API needs **two credentials together** for any data endpoint. This is non-obvious from the public docs and the symptom — `400 {"errorMessage":"InvalidPullTokenException failure"}` — burned us until we read the live swagger from the developer portal. Documenting it here so the next person isn't.
| Credential | Identifies | Where it goes | Required for |
|---|---|---|---|
| `GARMIN_ACCESS_TOKEN` (OAuth 2.0 PKCE Bearer) | The user | `Authorization: Bearer …` header | All endpoints (the user has to authorize the app) |
| `GARMIN_PULL_TOKEN` (Partner Pull Token) | The integration / app | `?token=…` query param | Every data endpoint — `/sleeps`, `/dailies`, `/activities`, `/stressDetails`, `/epochs`, `/respiration`, `/pulseOx`, `/hrv`, `/healthSnapshot`, `/activityDetails/*`, … |
The only endpoints that work with the Bearer alone (no pull token) are `/user/id` and `/user/permissions` — they're authentication/identity routes and Garmin doesn't gate them by integration. Every other endpoint listed in the wellness-api swagger has the `token` query param documented as *"Pull token required for OAuth2 integrations."* Omit it and you get `InvalidPullTokenException`.
**Where to get the pull token**: developer portal (`developerportal.garmin.com`) → your Health API app → **API Pull Token** tab. It's a static value; mint once and reuse. (Older forum threads suggest OAuth 1.0a HMAC-SHA1 signing instead — that's a legacy path being retired 2026-12-31. The live swagger is the authoritative source.)
## Why chunking?
The Wellness API caps every data-endpoint request at a **24-hour window** on `uploadStartTimeInSeconds`/`uploadEndTimeInSeconds`. Anything bigger and Garmin returns `400`. The MCP transparently splits any range into 24-hour chunks via `_split_into_chunks`, fires them concurrently through `httpx.AsyncClient` bounded by `asyncio.Semaphore(8)`, and concatenates the JSON arrays. Failed chunks are logged and skipped — partial data is more useful to an exploratory agent than a hard failure on any single chunk.
Note: `uploadStartTimeInSeconds` filters by **device upload time**, not the time the activity actually happened. If sync is delayed or the user hasn't opened the Garmin Connect app recently, recently-recorded data may not appear in the most recent chunks. There's a `summaryStart…` family on some Summary Endpoints worth exploring if upload-time filtering becomes a problem.