Content
# fitness_mcp
A generic, multi-platform fitness MCP server. It pulls data from **Garmin
Connect**, **Strava**, **Google Fit**, and **Suunto**, stores everything locally
in a single **DuckDB** file, and exposes analytics as **MCP tools** over stdio —
usable from Claude Desktop, Cursor, Windsurf, VS Code, or any MCP client.
- **Zero running infrastructure** — sync is manual, the server runs on demand.
- **Fully extensible** — adding a platform = implement one abstract class and
register it. The tools layer never changes.
- **Read-only by design** — MCP tools only read; `fitness_query` enforces
SELECT-only.
## Architecture
```
server.py MCP entry point (stdio) providers/base.py abstract interface + dataclasses
sync.py CLI + shared sync engine providers/garmin.py Garmin Connect (garth)
db/database.py DuckDB connection + upserts providers/strava.py Strava (OAuth2 + httpx)
db/schema.sql table definitions tools/*.py MCP tools (activities, health, …)
```
Data flow: `providers` fetch normalized records → `db` upserts them into DuckDB
→ `tools` query DuckDB and return a uniform JSON envelope.
Every tool returns:
```json
{ "success": true, "data": [...], "error": null, "meta": { "count": 42 } }
```
## Setup
### Automated (macOS / Linux)
```bash
./setup.sh # venv + deps + .env scaffold, then prints next steps
```
Then edit `.env`, and optionally let the script do the rest:
```bash
./setup.sh --login # interactive Garmin login (password never stored)
./setup.sh --sync # initial sync of all platforms
./setup.sh --claude # install the Claude Desktop MCP config
./setup.sh --dev # install dev deps and run the test suite
# flags combine: ./setup.sh --dev --login --sync --claude
```
A `Makefile` wraps the common actions — `make help`, `make setup`, `make login`,
`make sync`, `make serve`, `make test`, `make claude-install`.
### Automated (Windows / PowerShell)
```powershell
.\setup.ps1 # venv + deps + .env scaffold (locked to your user), then next steps
.\setup.ps1 -Dev -Login -Sync -Claude # switches combine, same meaning as above
```
If PowerShell blocks the script, run it once as:
```powershell
powershell -ExecutionPolicy Bypass -File .\setup.ps1
```
Then use the venv directly for the recurring commands:
```powershell
.\.venv\Scripts\python.exe login.py
.\.venv\Scripts\python.exe sync.py --platform garmin --full-history
.\.venv\Scripts\python.exe scripts\claude_config.py --write # Claude Desktop config
```
On Windows the setup script restricts `.env` to your user account via `icacls`
(the POSIX `chmod` warning is a no-op there); the garth token cache lives under
`%USERPROFILE%\.garth`, which is already user-scoped by default.
The Claude Desktop entry can be generated or installed on its own:
```bash
python scripts/claude_config.py # print the JSON snippet
python scripts/claude_config.py --write # merge it into your Claude config (with backup)
```
### Manual
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in credentials
```
### Credentials (`.env`)
```bash
DUCKDB_PATH=./fitness.duckdb
# Garmin Connect (via garth)
GARMIN_EMAIL=your@email.com
GARMIN_PASSWORD=yourpassword
GARTH_HOME=~/.garth # cached session token lives here
# Strava
STRAVA_CLIENT_ID=...
STRAVA_CLIENT_SECRET=...
STRAVA_REFRESH_TOKEN=...
```
`.env` and the DuckDB file are gitignored.
#### Strava: getting a refresh token (one time)
1. Create an API application at <https://www.strava.com/settings/api>. Note the
**Client ID** and **Client Secret**.
2. Authorize your own account, requesting the `activity:read_all` scope. Visit
(replace `CLIENT_ID`):
```
https://www.strava.com/oauth/authorize?client_id=CLIENT_ID&response_type=code&redirect_uri=http://localhost&approval_prompt=force&scope=activity:read_all
```
After approving, the browser redirects to `http://localhost/?...&code=AUTH_CODE&...`.
Copy `AUTH_CODE` from the URL.
3. Exchange the code for tokens:
```bash
curl -X POST https://www.strava.com/oauth/token \
-d client_id=CLIENT_ID -d client_secret=CLIENT_SECRET \
-d code=AUTH_CODE -d grant_type=authorization_code
```
Put the `refresh_token` from the response into `STRAVA_REFRESH_TOKEN`. The
server refreshes the short-lived access token automatically on every sync.
> Garmin uses `garth`. **Preferred login** — run it once interactively so your
> password is never written to disk:
>
> ```bash
> python login.py # prompts for email/password (+ MFA); caches a token
> ```
>
> After that you can leave `GARMIN_PASSWORD` out of `.env`; syncs reuse the
> cached session token. (Setting the password in `.env` still works as a
> fallback for non-interactive/headless use.)
## Security of credentials & sessions
- **Nothing secret is committed** — `.env`, the DuckDB file, and the garth token
cache (`.garth/`, `*.token`) are all gitignored.
- **Password stays off disk** — `python login.py` reads it via `getpass` and
only persists the resulting session token. Keep `GARMIN_PASSWORD` blank.
- **Owner-only token cache** — after every Garmin login the `GARTH_HOME`
directory and its token files are `chmod`'d to `0700`/`0600` (POSIX).
- **Exposure warning** — `sync.py` and `server.py` warn on startup if your
`.env` is group/world-readable, with the exact `chmod 600` fix.
- **Treat session tokens like a password** — a cached garth token grants access
to your Garmin account. If a machine is compromised, delete `GARTH_HOME` and
re-run `login.py`. OAuth refresh tokens (Strava/Google/Suunto) are likewise
sensitive; revoke them in each platform's app settings if leaked.
## Syncing data
```bash
python sync.py --platform all # last 30 days, all platforms
python sync.py --platform garmin # garmin | strava | google_fit | suunto
python sync.py --platform garmin --from 2025-01-01 --to 2025-06-30
python sync.py --platform garmin --full-history # everything from 2010 (first run)
```
Supported platforms: `garmin`, `strava`, `google_fit`, `suunto`. Garmin is the
richest source (activities, sleep, HRV, body battery, stress); Strava and Suunto
provide activities; Google Fit provides activities, sleep, and weight.
After all platforms sync, duplicate workouts (same day + sport, duration and
distance within 5%) are de-duplicated: the Garmin record is kept (richer
metrics) and the Strava id is merged into its `raw_json`.
### Recurring (automated) sync
After a one-time `python login.py`, the Garmin token auto-refreshes, so
scheduled syncs run unattended for months (re-run `login.py` only when the
long-lived token finally expires). The runner logs each run to `logs/sync.log`
and exits non-zero if any platform errored:
```bash
python scripts/scheduled_sync.py --platform all
```
**Windows (Task Scheduler):**
```powershell
.\scripts\register_sync_task.ps1 # daily 07:00, all platforms
.\scripts\register_sync_task.ps1 -Time 06:30 -Platform garmin
.\scripts\register_sync_task.ps1 -Daily2x # 07:00 and 19:00
.\scripts\register_sync_task.ps1 -Unregister # remove it
```
The task runs as your user via S4U (no stored password, runs whether or not
you're logged in) and catches up missed runs. Trigger a test run with
`Get-ScheduledTask fitnessmcp-sync | Start-ScheduledTask`.
**macOS/Linux (cron):** point cron at the same runner —
```cron
0 7 * * * cd /path/to/fitMCP && .venv/bin/python scripts/scheduled_sync.py --platform all
```
## Running the MCP server
```bash
python server.py # serves over stdio
```
### Claude Desktop config
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"fitness": {
"command": "/absolute/path/to/fitness-mcp/.venv/bin/python",
"args": ["/absolute/path/to/fitness-mcp/server.py"],
"env": { "PYTHONPATH": "/absolute/path/to/fitness-mcp" }
}
}
}
```
## MCP tools
| Tool | Purpose |
|---|---|
| `fitness_sync(platform)` | Sync garmin/strava/all into DuckDB |
| `fitness_sync_status()` | Last sync time + record counts per platform |
| `fitness_get_database_stats()` | Row counts, date ranges, file size |
| `fitness_get_activities(date_from, date_to, platform, sport_type, limit, offset)` | Paginated activity list |
| `fitness_get_activity_detail(activity_id)` | Full detail incl. raw payload |
| `fitness_get_personal_bests(sport_type)` | Longest, fastest, max HR, most elevation |
| `fitness_get_sleep(date_from, date_to, platform)` | Sleep records |
| `fitness_get_hrv(date_from, date_to, platform)` | HRV + body battery |
| `fitness_get_body_battery(date_from, date_to)` | Body battery trend |
| `fitness_get_recovery_status(date)` | One-day recovery snapshot |
| `fitness_get_training_load(weeks, platform)` | Weekly load trend |
| `fitness_get_vo2max_trend(months)` | VO2max estimates over time |
| `fitness_get_weekly_summary(week_offset)` | Week totals + sport split |
| `fitness_get_sport_breakdown(date_from, date_to)` | Time/distance/count per sport |
| `fitness_compare_platforms(metric, date_from, date_to)` | Same metric per platform |
| `fitness_correlate(metric_a, metric_b, date_from, date_to)` | Pearson correlation + scatter |
| `fitness_get_trends(metric, date_from, date_to, granularity)` | Time series by day/week/month |
| `fitness_query(sql)` | Read-only SELECT against the database |
Metrics for `fitness_correlate` / `fitness_get_trends`: `sleep_score`,
`sleep_duration`, `hrv`, `hrv_score`, `body_battery`, `training_load`,
`distance`, `duration`, `avg_hr`, `stress`, `weight`.
## FitBrain — coaching layer
For a data-driven coaching experience, create a Claude Project named **FitBrain**
and paste [`prompts/fitbrain_system_prompt.md`](prompts/fitbrain_system_prompt.md)
into its custom instructions (edit the "Who I am"/"Goals" to match you). It makes
Claude always check freshness and pull live data before answering, hunt for
correlations, flag overtraining signals, and stay direct. See
[`prompts/README.md`](prompts/README.md) for setup and example questions.
## Tests
```bash
pip install -r requirements-dev.txt
PYTHONPATH=. pytest
```
The suite covers the DuckDB layer (upsert idempotency, dedup, sync log),
provider payload parsing for all four platforms, the read-only SQL guard, and
every MCP tool end-to-end against a seeded temp database — no network or
credentials required. CI runs it on every push and pull request
(`.github/workflows/tests.yml`).
## Adding a new platform
1. Create `providers/newplatform.py` extending `FitnessProvider`.
2. Add credentials to `.env`.
3. Register it in `sync.py`: `PROVIDERS["newplatform"] = NewPlatformProvider`.
Done — every tool includes it automatically when `platform="all"`.
## Project status
Implemented: foundation, all four providers (Garmin, Strava, Google Fit,
Suunto), cross-platform dedup, the full tools layer (activities, health,
training, analysis, sync), credential/session hardening, automated setup
(macOS/Linux/Windows), scheduled recurring sync, the FitBrain coaching prompt,
and a pytest suite with CI. Google Fit has no HRV and Suunto exposes only
workouts via its public API; both still satisfy the common `FitnessProvider`
interface, so the tools layer treats them uniformly.
What's left is live validation against real accounts (needs your credentials).
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available