health-mcp
README.md
# health_mcp
Personal health data for Claude: **Garmin Connect** and **Technogym mywellness**, served as one
MCP endpoint that runs on a Windows machine, starts with it, and is reachable from claude.ai
anywhere through a Cloudflare Tunnel.
```
claude.ai ─HTTPS─▶ Cloudflare Tunnel ─▶ cloudflared (service)
│ http://127.0.0.1:18080/s/<secret>/mcp
▼
health-caddy (Caddy, loopback :18080)
│ strips /s/<secret>/, 404s everything else
▼
health-mcp (aggregator, loopback :18001)
├─ garmin_* ─▶ garmin-mcp (loopback :18000)
└─ technogym_* ─▶ technogym-mcp (loopback :<port>/<secret>/mcp)
```
Everything is a Windows service running as LOCAL SYSTEM. No inbound ports are opened;
cloudflared dials out. The only thing between the internet and your data is the secret path,
so the URL is treated like a password.
## Repository layout
| Path | What |
|---|---|
| `src/health_mcp/` | The aggregator. FastMCP 4 `ProxyProvider`s expose upstream tools as `garmin_*` and `technogym_*`. `GET /healthz` checks both upstreams. |
| `src/health_mcp/combined.py` | The `combined_*` tools: one workout record merged from both sources, matched by time overlap (see below). |
| `technogym/` | [technogym-mcp](technogym/README.md): MCP server for Technogym mywellness (own `pyproject.toml`, `.env`, tests). Merged from the former `technogym_mcp` repo with history. |
| `deploy/install.ps1` | One idempotent installer for all four services. |
| `deploy/install-cloudflared.ps1` | Installs the Cloudflare Tunnel connector as a service (token based). |
| `deploy/Caddyfile.template` | The secret-path gate. |
| `deploy/enabled_tools.txt` | Garmin tool allowlist (read-only tools only). |
| `deploy/list_garmin_tools.py` | Lists every tool the installed garmin-mcp offers. |
| `scripts/list_tools.py` | Lists the tools health-mcp exposes. |
Garmin's MCP server itself is upstream [Taxuspt/garmin_mcp](https://github.com/Taxuspt/garmin_mcp),
installed as a `uv tool`; nothing of it is vendored here.
## Machine layout
| Path | Purpose |
|---|---|
| `C:\health-mcp\secret.txt` | URL secret. Treat like a password. |
| `C:\health-mcp\Caddyfile` | Rendered gate config (contains the secret). |
| `C:\health-mcp\enabled_tools.txt` | Active Garmin allowlist. |
| `C:\health-mcp\logs\*.log` | One rotated log per service. |
| `%USERPROFILE%\.garminconnect` | Garmin OAuth tokens. |
| `%USERPROFILE%\.local\bin\garmin-mcp.exe` | Garmin server binary. |
| `technogym\.env` | Technogym credentials, port and secret path (never committed). |
| `.env` | Upstream URLs for the aggregator, `HEALTH_MCP_STATELESS=1` (never committed). |
Services: `garmin-mcp`, `technogym-mcp`, `health-mcp` (depends on both), `health-caddy`
(depends on health-mcp), `cloudflared`.
## Setup from scratch
### 1. Tools
```powershell
winget install --id Git.Git
winget install --id astral-sh.uv
winget install --id CaddyServer.Caddy
winget install --id Cloudflare.cloudflared
winget install --id NSSM.NSSM
```
Reopen PowerShell, then:
```powershell
uv tool install --python 3.12 git+https://github.com/Taxuspt/garmin_mcp
garmin-mcp-auth # interactive Garmin login (email, password, MFA). Once.
garmin-mcp-auth --verify
```
### 2. Configure
```powershell
git clone https://github.com/ErikAnkerKilbergSkallevold/health_mcp
cd health_mcp
copy technogym\.env.example technogym\.env # mywellness email+password, a port (e.g. 38472),
# and TECHNOGYM_MCP_PATH=/<32 hex>/mcp
copy .env.example .env # TECHNOGYM_MCP_URL = http://127.0.0.1:<port><that path>
```
### 3. Install the services
Elevated PowerShell:
```powershell
.\deploy\install.ps1
```
Generates the secret on first run, renders the Caddyfile, syncs both Python environments,
(re)creates the four services and prints probe results plus the connector path. Re-run it after
changing `deploy\enabled_tools.txt` or either `.env`.
Expected:
```
garmin http://127.0.0.1:18000/healthz -> 200 ok
technogym http://127.0.0.1:38472/healthz -> 200 ok
health http://127.0.0.1:18001/healthz -> 200 {"status":"ok","upstreams":{"garmin":"ok","technogym":"ok"}}
gated http://127.0.0.1:18080/s/<secret>/healthz -> 200
nosecret http://127.0.0.1:18080/healthz -> 404
```
### 4. Cloudflare Tunnel
Needs a Cloudflare account (Free) and a domain whose nameservers point at Cloudflare. For a
`.no` domain from Domeneshop: buy it with the free DNS option, add the domain in Cloudflare,
then set the two Cloudflare nameservers under "Egne navnetjenere". Norid publishes the change
within about an hour.
1. Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared. Name it after the machine.
2. Pick Windows and copy the token from the shown `service install` command.
3. Published application route: subdomain `health`, your domain, HTTP, `localhost:18080`, Path empty.
4. On the machine, elevated: `.\deploy\install-cloudflared.ps1 -Token <token>`
One tunnel per machine. More apps later are just more routes on the same tunnel.
In the zone's **AI Crawl Control**, set **Agent** to Allow, or claude.ai's requests may be blocked.
### 5. claude.ai
Settings → Connectors → **Yours** → Add custom connector. URL:
```
https://health.<domain>/s/<contents of C:\health-mcp\secret.txt>/mcp
```
No OAuth fields. Custom connectors are per account: on Team/Enterprise plans only Owners can add
them, a Free account gets one, Pro/Max unlimited.
## Combined tools (`combined_*`)
Garmin and Technogym record the same workout from different angles and know nothing of each
other's IDs. The `combined_*` tools pair them by time overlap and merge them:
| Tool | What it returns |
|---|---|
| `combined_get_workouts(from_date, to_date?)` | Gym workouts from both sides in a range, paired by overlap: `matched`, `technogym_only` or `garmin_only`, with confidence, the clock offset between watch and console, and the IDs for `get_workout`. |
| `combined_get_workout(date, technogym_session_id?, garmin_activity_id?, include_timeseries=false, sample_every_seconds=10, from_sec=0, to_sec=0)` | **Runs**: Technogym intervals and laps each enriched with Garmin HR, power and cadence for that window; a merged summary where every metric names its source and carries the other side's value as `alt`; HR zones, training effect/load, Garmin splits, power-duration curve; optional joined per-second series. **Strength**: Technogym exercises and sets (reps, weight) paired in order with Garmin's timed sets, so each set gets start time, duration and HR; per-exercise HR; free-weight sets Garmin saw but Technogym did not; total volume, rest times, zones, training effect/load. |
Merge rule: Technogym is authoritative for what the machine did (speed, grade, belt distance,
mill-measured cadence and running power, sets / reps / weight); Garmin for what the body did
(heart rate, zones, training effect and load, calories, recovery). Nothing is discarded: where
both report a metric the chosen value is marked with `source` and the other kept under `alt`.
Alignment is by wall clock, so the 10-20 s between pressing start on the watch and on the
console is accounted for (`clock_offset_sec`).
## Tools
`deploy\enabled_tools.txt` limits Garmin to read-only tools (every `get_*`/`search_*` plus
`count_activities`, 106 of 150). Write tools such as uploading workouts, editing activities or
logging food are excluded because the URL alone guards the account. Trim it further to save
context per chat and re-run the installer. Technogym is read-only by design.
```powershell
uv run python scripts\list_tools.py # what the connector exposes
& "$env:APPDATA\uv\tools\garmin-mcp\Scripts\python.exe" deploy\list_garmin_tools.py "$env:USERPROFILE\.local\bin\garmin-mcp.exe"
```
## Operations
| Task | Command |
|---|---|
| Status | `Get-Service garmin-mcp, technogym-mcp, health-mcp, health-caddy, cloudflared` |
| Logs | `Get-Content C:\health-mcp\logs\health-mcp.log -Tail 50 -Wait` |
| Restart everything | `Restart-Service garmin-mcp -Force` (dependents restart with it) |
| Upgrade garmin-mcp | `uv tool upgrade garmin-mcp; Restart-Service garmin-mcp -Force` |
| Update this repo | `git pull; .\deploy\install.ps1` |
| Re-auth Garmin | `garmin-mcp-auth --force-reauth; Restart-Service garmin-mcp -Force` |
| Rotate the secret | delete `C:\health-mcp\secret.txt`, re-run installer, update the connector URL |
| Uninstall | `foreach ($s in 'health-caddy','health-mcp','technogym-mcp','garmin-mcp') { nssm remove $s confirm }; cloudflared service uninstall` |
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `421 Misdirected Request` from `/mcp` | An upstream rejects non-local Host headers. Caddy sets `header_up Host {upstream_hostport}`; check the rendered Caddyfile. |
| Phone/claude.ai says the connector failed; log shows `POST /mcp 400` right after `Created new transport` | Client sent calls without a session id. health-mcp must run stateless (`HEALTH_MCP_STATELESS=1`, the default); check the "listening ... (stateless)" line in `health-mcp.log`. |
| `/healthz` says `"technogym": "down"` | `Get-Service technogym-mcp`, then `C:\health-mcp\logs\technogym-mcp.log`. Usually a wrong port or path in `.env`. |
| Cloudflare shows "Invalid nameservers" on a new domain | Registry publishing lag. Wait, then "Check nameservers". |
| Connector gets 403 from Cloudflare | AI Crawl Control → Agent → Allow; Security → Settings → "Block AI bots" off. |
| Service stuck in `StopPending` | The installer kills the nssm host after 15 s; manually: `sc queryex <name>` then `taskkill /F /T /PID <pid>`. |
| Tokens invalid after a while | Garmin tokens expire eventually: `garmin-mcp-auth --force-reauth`. |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues