Skip to main content
Glama
README.md
# Garmin MCP

> **Project status (2026-08-23): personal-tunnel implementation; local smoke tests pass.**
> Do not enable friend access or public MCP ingress. Real Garmin credentials remain blocked until the
> operator explicitly approves the separate public HTTPS link-web tunnel and completes the live-test
> checklist in [`PERSONAL_TUNNEL_RUNBOOK.md`](./PERSONAL_TUNNEL_RUNBOOK.md).

Multi-user, read-only MCP resource server that wraps
[`cyberjunky/python-garminconnect`](https://github.com/cyberjunky/python-garminconnect).
It exposes authenticated Garmin data without accepting a user ID as a tool argument.

## Security model

The bullets below describe the original design intent. They are not yet all proven by the current
implementation; the known gaps and required acceptance tests are tracked in
[`DESIGN_DRAFT.md`](./DESIGN_DRAFT.md).

- MCP bearer tokens are verified separately from Garmin authentication. Production OIDC validates
  signature, issuer, audience, expiry, required scopes, and uses the verified `sub` claim.
- Garmin email, password, and MFA codes exist only during their request. They are not persisted or
  logged. Uvicorn access logging is disabled.
- Only `python-garminconnect`'s serialized session JSON is stored, encrypted with AES-256-GCM and
  authenticated against the owning principal so ciphertext cannot be moved between users.
- MFA clients are process-local, single-use, owner-bound, and expire after five minutes.
- Connection URLs place their signed one-time state in the fragment. External JavaScript clears the
  fragment and moves state into a POST body, keeping it out of HTTP and proxy access logs.
- Disconnect deletes the local encrypted tokens. This does not revoke a token already issued by
  Garmin; revoke access in Garmin account security settings if compromise is suspected.

## Personal ChatGPT tunnel mode

The personal setup deliberately uses two listeners:

- `SERVER_ROLE=mcp` on loopback port 3000. Only OpenAI Secure MCP Tunnel may reach this port.
- `SERVER_ROLE=link-web` on loopback port 3001. A separate HTTPS tunnel may expose only this
  listener so a phone can enter Garmin credentials without exposing the MCP endpoint.

`AUTH_MODE=tunnel` has one configured `TUNNEL_PRINCIPAL_ID`; it is not a multi-user mode. The OpenAI
tunnel is the authentication boundary, and the server refuses combined mode or a non-loopback bind
unless container isolation is explicitly enabled. See
[`PERSONAL_TUNNEL_RUNBOOK.md`](./PERSONAL_TUNNEL_RUNBOOK.md) for the end-to-end setup.

## Run locally

For a Windows setup covering every dependency, both tunnels, ChatGPT, and troubleshooting, see
[`INSTALLATION_HANDBOOK.md`](./INSTALLATION_HANDBOOK.md).

After the one-time setup on this Windows PC, start the local services and OpenAI tunnel with:

```powershell
.\start-garmin-mcp.cmd
```

The launcher is safe to run again: healthy services are left running instead of duplicated. The
OpenAI Runtime API key is requested in a separate window and kept only in process memory. Useful
commands are `status`, `restart`, and `stop`:

```powershell
.\start-garmin-mcp.cmd status
.\start-garmin-mcp.cmd restart
.\start-garmin-mcp.cmd stop
```

It deliberately does not expose the Garmin credential page. If the Garmin session must be linked
again, first open a temporary HTTPS tunnel to port 3001 and then run:

```powershell
.\start-garmin-mcp.cmd restart -LinkPublicBaseUrl https://<fresh-hostname>
```

Requires Python 3.12+, PostgreSQL 17, and `uv`.

```bash
cp .env.example .env
# Fill secrets, then for local-only bearer authentication set AUTH_MODE=development.
uv sync --all-extras
psql "$DATABASE_URL" -f migrations/001_initial.sql
uv run garmin-mcp
```

Or run `docker compose up --build` after creating `.env`. Compose publishes both application ports
to `127.0.0.1` only and does not publish PostgreSQL. The MCP endpoint is `/mcp`; the health endpoint
is `/healthz`. In development mode the bearer value itself is the local user ID. Never expose
development mode beyond loopback.

## MCP tools

- `get_connection_status`
- `connect_garmin` — returns a ten-minute, single-use browser URL
- `get_profile`
- `get_sleep`, `get_daily_summary`, `get_steps`, `get_heart_rate`, `get_hrv`
- `get_body_battery`, `get_stress`, `get_training_readiness`
- `list_activities`, `get_activity`
- all 106 public `get_*`/`count_*` methods from pinned `garminconnect==0.3.11`, registered as
  explicit tools under their upstream names
- `list_garmin_data_operations` and `get_garmin_data` — discovery and bounded generic fallback
- `download_activity_file`, `download_health_snapshot`, `download_workout_file` — bounded file
  exports returned as MCP embedded resources
- `disconnect_garmin`

Garmin data tools are read-only. `disconnect_garmin` mutates only local connection state by deleting
the stored session. Account credentials are entered only into the browser linking page, never
supplied as MCP tool arguments. The running server exposes 122 tools. See
[`API_COVERAGE.md`](./API_COVERAGE.md) for upstream coverage, validation bounds, and deliberate
exclusions.

## Validation

```bash
uv run ruff check .
uv run mypy src
uv run pytest
uv build
uv run pip-audit
```

Do not put real Garmin credentials in CI. Any future live test must use a dedicated account and an
explicit integration-test marker.