Skip to main content
Glama
README.md
# 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).