From 1b1e90f9d7ecef833a7c4b0eb85b36da9215efc1 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 17 May 2026 19:37:05 +0200 Subject: [PATCH] chore: project scaffolding and design docs --- .env.example | 2 + .gitignore | 7 + .../plans/2026-05-17-mcp-garmin.md | 1005 +++++++++++++++++ .../specs/2026-05-17-mcp-garmin-design.md | 126 +++ pyproject.toml | 13 + 5 files changed, 1153 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docs/superpowers/plans/2026-05-17-mcp-garmin.md create mode 100644 docs/superpowers/specs/2026-05-17-mcp-garmin-design.md create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c5a6b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +GARMIN_EMAIL=your@email.com +GARMIN_PASSWORD=yourpassword diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af2d523 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.garth/ +.venv/ +__pycache__/ +*.pyc +.idea/ +dist/ diff --git a/docs/superpowers/plans/2026-05-17-mcp-garmin.md b/docs/superpowers/plans/2026-05-17-mcp-garmin.md new file mode 100644 index 0000000..b220142 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-mcp-garmin.md @@ -0,0 +1,1005 @@ +# MCP Garmin Server — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a single-file FastMCP stdio server exposing 15 Garmin Connect tools to Claude Desktop, with interactive MFA handling from within chat. + +**Architecture:** `server.py` holds all tools and a module-level `Garmin` client shared across calls. Auth state is a global string flag. MFA is handled by running login in a background thread that blocks on a `queue.Queue`; `complete_mfa()` unblocks it. Startup attempts a silent login using cached `~/.garth` tokens, falling back gracefully if none exist. + +**Tech Stack:** Python 3.14, `mcp[cli]` (FastMCP), `garminconnect`, `pytest`, `unittest.mock` + +--- + +## File Map + +| File | Purpose | +|------|---------| +| `pyproject.toml` | Project metadata and pip dependencies | +| `.env.example` | Template for required env vars (committed) | +| `.gitignore` | Excludes `.env`, `.garth/`, `.venv/` | +| `server.py` | All 15 MCP tools + auth state + startup logic | +| `tests/__init__.py` | Empty — makes tests/ a package | +| `tests/test_server.py` | Unit tests with mocked Garmin client | + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `pyproject.toml` +- Create: `.env.example` +- Create: `.gitignore` + +- [ ] **Step 1: Create `pyproject.toml`** + +```toml +[project] +name = "mcp-garmin" +version = "0.1.0" +description = "MCP server for Garmin Connect" +requires-python = ">=3.11" +dependencies = [ + "mcp[cli]", + "garminconnect", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +- [ ] **Step 2: Create `.env.example`** + +``` +GARMIN_EMAIL=your@email.com +GARMIN_PASSWORD=yourpassword +``` + +- [ ] **Step 3: Create `.gitignore`** + +``` +.env +.garth/ +.venv/ +__pycache__/ +*.pyc +.idea/ +dist/ +``` + +- [ ] **Step 4: Install dependencies** + +```bash +.venv/bin/pip install "mcp[cli]" garminconnect pytest +``` + +Expected: All packages install without errors. Verify with: +```bash +.venv/bin/python -c "from garminconnect import Garmin; from mcp.server.fastmcp import FastMCP; print('OK')" +``` +Expected output: `OK` + +- [ ] **Step 5: Initialize git and commit** + +```bash +git init +git add pyproject.toml .env.example .gitignore docs/ +git commit -m "chore: project scaffolding and design docs" +``` + +--- + +### Task 2: Server Skeleton + Auth Tools + +**Files:** +- Create: `server.py` +- Create: `tests/__init__.py` +- Create: `tests/test_server.py` + +- [ ] **Step 1: Write failing tests for auth tools** + +Create `tests/__init__.py` as an empty file, then create `tests/test_server.py`: + +```python +import json +import queue +import threading +from unittest.mock import MagicMock, patch + +import pytest + +import server + + +@pytest.fixture(autouse=True) +def reset_state(): + original_state = server._auth_state + original_client = server._client + for q in (server._mfa_input_queue, server._login_result_queue): + while not q.empty(): + try: + q.get_nowait() + except queue.Empty: + break + yield + server._auth_state = original_state + server._client = original_client + + +def test_check_auth_unauthenticated(): + server._auth_state = "unauthenticated" + assert server._check_auth() == "Not authenticated. Ask Claude to call authenticate() first." + + +def test_check_auth_authenticated(): + server._auth_state = "authenticated" + assert server._check_auth() is None + + +def test_authenticate_success(): + with patch("server.Garmin") as mock_garmin_cls: + mock_garmin_cls.return_value = MagicMock() + # mock login() returns immediately, thread puts success in queue + result = server.authenticate() + assert result == "Authenticated successfully." + assert server._auth_state == "authenticated" + + +def test_authenticate_mfa_required(): + with patch("server.Garmin") as mock_garmin_cls: + mock_garmin_cls.return_value = MagicMock() + with patch.object(server._login_result_queue, "get", side_effect=queue.Empty): + result = server.authenticate() + assert "MFA required" in result + assert server._auth_state == "mfa_pending" + + +def test_complete_mfa_success(): + server._auth_state = "mfa_pending" + server._login_result_queue.put(("success", None)) + result = server.complete_mfa("123456") + assert result == "MFA accepted. Authenticated successfully." + assert server._auth_state == "authenticated" + assert server._mfa_input_queue.get_nowait() == "123456" + + +def test_complete_mfa_not_in_progress(): + server._auth_state = "unauthenticated" + result = server.complete_mfa("123456") + assert result == "No MFA in progress. Call authenticate() first." +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +.venv/bin/pytest tests/test_server.py -v 2>&1 | head -20 +``` + +Expected: `ModuleNotFoundError: No module named 'server'` — server.py doesn't exist yet. + +- [ ] **Step 3: Create `server.py`** + +```python +import json +import os +import queue +import threading +from datetime import date + +from garminconnect import Garmin, GarminConnectAuthenticationError +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("garmin") + +_client: Garmin | None = None +_auth_state: str = "unauthenticated" +_mfa_input_queue: queue.Queue = queue.Queue() +_login_result_queue: queue.Queue = queue.Queue() + + +def _today() -> str: + return date.today().isoformat() + + +def _check_auth() -> str | None: + if _auth_state != "authenticated": + return "Not authenticated. Ask Claude to call authenticate() first." + return None + + +def _prompt_mfa() -> str: + return _mfa_input_queue.get(timeout=300) + + +def _startup_login() -> None: + global _client, _auth_state + email = os.environ.get("GARMIN_EMAIL") + password = os.environ.get("GARMIN_PASSWORD") + if not email or not password: + raise SystemExit( + "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required." + ) + try: + _client = Garmin(email, password) + _client.login() + _auth_state = "authenticated" + except Exception: + _auth_state = "unauthenticated" + + +@mcp.tool() +def authenticate() -> str: + """Initiate Garmin authentication using credentials from environment variables.""" + global _client, _auth_state + + def _do_login() -> None: + try: + _client.login() + _login_result_queue.put(("success", None)) + except Exception as exc: + _login_result_queue.put(("error", str(exc))) + + _client = Garmin(os.environ["GARMIN_EMAIL"], os.environ["GARMIN_PASSWORD"]) + _client.prompt_mfa = _prompt_mfa + threading.Thread(target=_do_login, daemon=True).start() + + try: + status, err = _login_result_queue.get(timeout=10) + if status == "success": + _auth_state = "authenticated" + return "Authenticated successfully." + return f"Authentication failed: {err}" + except queue.Empty: + _auth_state = "mfa_pending" + return ( + "MFA required. Garmin has sent a verification code to your registered " + "email or phone. Call complete_mfa(code) with the code you received." + ) + + +@mcp.tool() +def complete_mfa(code: str) -> str: + """Provide the MFA verification code to complete Garmin authentication.""" + global _auth_state + + if _auth_state != "mfa_pending": + return "No MFA in progress. Call authenticate() first." + + _mfa_input_queue.put(code) + + try: + status, err = _login_result_queue.get(timeout=30) + if status == "success": + _auth_state = "authenticated" + return "MFA accepted. Authenticated successfully." + return f"Authentication failed after MFA: {err}" + except queue.Empty: + return "Timed out waiting for authentication to complete." + + +if __name__ == "__main__": + _startup_login() + mcp.run() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 6 tests pass. Example output: +``` +test_server.py::test_check_auth_unauthenticated PASSED +test_server.py::test_check_auth_authenticated PASSED +test_server.py::test_authenticate_success PASSED +test_server.py::test_authenticate_mfa_required PASSED +test_server.py::test_complete_mfa_success PASSED +test_server.py::test_complete_mfa_not_in_progress PASSED +``` + +- [ ] **Step 5: Commit** + +```bash +git add server.py tests/__init__.py tests/test_server.py +git commit -m "feat: server skeleton with authenticate and complete_mfa tools" +``` + +--- + +### Task 3: Activity Tools + +**Files:** +- Modify: `server.py` (add `get_activities`, `get_activity_details`) +- Modify: `tests/test_server.py` (add activity tests) + +- [ ] **Step 1: Write failing tests for activity tools** + +Add the following imports to the top import block of `tests/test_server.py` (after `import pytest`): + +```python +from datetime import date +from garminconnect import GarminConnectAuthenticationError +``` + +Then append to the bottom of `tests/test_server.py`: + +```python +def test_get_activities_unauthenticated(): + server._auth_state = "unauthenticated" + assert "Not authenticated" in server.get_activities() + + +def test_get_activities_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activities_by_date.return_value = [ + {"activityId": "111", "activityType": {"typeKey": "running"}, "distance": 5000.0}, + {"activityId": "222", "activityType": {"typeKey": "cycling"}, "distance": 20000.0}, + ] + result = server.get_activities("2026-05-01", "2026-05-17", 20) + data = json.loads(result) + assert len(data) == 2 + assert data[0]["activityId"] == "111" + server._client.get_activities_by_date.assert_called_once_with("2026-05-01", "2026-05-17") + + +def test_get_activities_limit(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activities_by_date.return_value = [{"activityId": str(i)} for i in range(10)] + data = json.loads(server.get_activities("2026-05-01", "2026-05-17", 3)) + assert len(data) == 3 + + +def test_get_activities_defaults_to_today(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activities_by_date.return_value = [] + server.get_activities() + today = date.today().isoformat() + server._client.get_activities_by_date.assert_called_once_with(today, today) + + +def test_get_activities_auth_error(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activities_by_date.side_effect = GarminConnectAuthenticationError("auth") + assert "Authentication error" in server.get_activities() + + +def test_get_activity_details_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activity_details.return_value = {"activityId": "111", "laps": []} + data = json.loads(server.get_activity_details("111")) + assert data["activityId"] == "111" + server._client.get_activity_details.assert_called_once_with("111") + + +def test_get_activity_details_error(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_activity_details.side_effect = Exception("not found") + assert "Error fetching activity details" in server.get_activity_details("999") +``` + +- [ ] **Step 2: Run tests to verify new ones fail** + +```bash +.venv/bin/pytest tests/test_server.py::test_get_activities_success -v +``` + +Expected: `AttributeError: module 'server' has no attribute 'get_activities'` + +- [ ] **Step 3: Add activity tools to `server.py`** + +Add the following before the `if __name__ == "__main__":` block: + +```python +@mcp.tool() +def get_activities(start_date: str = "", end_date: str = "", limit: int = 20) -> str: + """List Garmin activities between two dates (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + start = start_date or _today() + end = end_date or _today() + try: + result = _client.get_activities_by_date(start, end) + return json.dumps(result[:limit], indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching activities: {exc}" + + +@mcp.tool() +def get_activity_details(activity_id: str) -> str: + """Get full details for a single activity by its ID (splits, laps, metrics).""" + if err := _check_auth(): + return err + try: + result = _client.get_activity_details(activity_id) + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching activity details: {exc}" +``` + +- [ ] **Step 4: Run all tests to verify they pass** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 13 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add server.py tests/test_server.py +git commit -m "feat: add get_activities and get_activity_details tools" +``` + +--- + +### Task 4: Health Stats Tools + +**Files:** +- Modify: `server.py` (add 6 health tools) +- Modify: `tests/test_server.py` (add health tests) + +- [ ] **Step 1: Write failing tests for health tools** + +Append to the bottom of `tests/test_server.py`: + +```python +def test_get_sleep_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_sleep_data.return_value = { + "dailySleepDTO": {"sleepScores": {"overall": {"value": 85}}} + } + data = json.loads(server.get_sleep("2026-05-17")) + assert data["dailySleepDTO"]["sleepScores"]["overall"]["value"] == 85 + server._client.get_sleep_data.assert_called_once_with("2026-05-17") + + +def test_get_sleep_no_data(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_sleep_data.return_value = None + assert server.get_sleep("2026-05-17") == "No sleep data found for 2026-05-17" + + +def test_get_heart_rate_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_heart_rates.return_value = {"restingHeartRate": 52} + data = json.loads(server.get_heart_rate("2026-05-17")) + assert data["restingHeartRate"] == 52 + server._client.get_heart_rates.assert_called_once_with("2026-05-17") + + +def test_get_stress_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_stress_data.return_value = {"stressValuesArray": [[1000, 35]]} + data = json.loads(server.get_stress("2026-05-17")) + assert "stressValuesArray" in data + + +def test_get_body_battery_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_body_battery.return_value = [{"bodyBatteryStatList": []}] + data = json.loads(server.get_body_battery("2026-05-17")) + assert isinstance(data, list) + server._client.get_body_battery.assert_called_once_with("2026-05-17", "2026-05-17") + + +def test_get_hrv_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_hrv_data.return_value = {"hrvSummary": {"weeklyAvg": 45}} + data = json.loads(server.get_hrv("2026-05-17")) + assert data["hrvSummary"]["weeklyAvg"] == 45 + + +def test_get_spo2_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_spo2_data.return_value = {"spO2HourlyAverages": []} + data = json.loads(server.get_spo2("2026-05-17")) + assert "spO2HourlyAverages" in data + + +def test_health_tools_unauthenticated(): + server._auth_state = "unauthenticated" + for fn in (server.get_sleep, server.get_heart_rate, server.get_stress, + server.get_body_battery, server.get_hrv, server.get_spo2): + assert "Not authenticated" in fn("2026-05-17") +``` + +- [ ] **Step 2: Run tests to verify new ones fail** + +```bash +.venv/bin/pytest tests/test_server.py::test_get_sleep_success -v +``` + +Expected: `AttributeError: module 'server' has no attribute 'get_sleep'` + +- [ ] **Step 3: Add health tools to `server.py`** + +Add the following before the `if __name__ == "__main__":` block: + +```python +@mcp.tool() +def get_sleep(date: str = "") -> str: + """Get sleep data for a date (YYYY-MM-DD): score, stages, duration. Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_sleep_data(cdate) + if not result: + return f"No sleep data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching sleep data: {exc}" + + +@mcp.tool() +def get_heart_rate(date: str = "") -> str: + """Get heart rate data for a date (YYYY-MM-DD): resting HR and daily timeline. Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_heart_rates(cdate) + if not result: + return f"No heart rate data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching heart rate data: {exc}" + + +@mcp.tool() +def get_stress(date: str = "") -> str: + """Get stress level timeline for a date (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_stress_data(cdate) + if not result: + return f"No stress data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching stress data: {exc}" + + +@mcp.tool() +def get_body_battery(date: str = "") -> str: + """Get body battery charge/drain values for a date (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_body_battery(cdate, cdate) + if not result: + return f"No body battery data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching body battery data: {exc}" + + +@mcp.tool() +def get_hrv(date: str = "") -> str: + """Get HRV status and nightly average for a date (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_hrv_data(cdate) + if not result: + return f"No HRV data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching HRV data: {exc}" + + +@mcp.tool() +def get_spo2(date: str = "") -> str: + """Get SpO2 (blood oxygen) readings for a date (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_spo2_data(cdate) + if not result: + return f"No SpO2 data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching SpO2 data: {exc}" +``` + +- [ ] **Step 4: Run all tests to verify they pass** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 21 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add server.py tests/test_server.py +git commit -m "feat: add health stats tools (sleep, HR, stress, body battery, HRV, SpO2)" +``` + +--- + +### Task 5: Steps & Daily Metrics Tools + +**Files:** +- Modify: `server.py` (add `get_steps`, `get_daily_stats`) +- Modify: `tests/test_server.py` (add steps/metrics tests) + +- [ ] **Step 1: Write failing tests** + +Append to the bottom of `tests/test_server.py`: + +```python +def test_get_steps_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_steps_data.return_value = [{"steps": 8342, "primaryActivityLevel": "active"}] + data = json.loads(server.get_steps("2026-05-17")) + assert data[0]["steps"] == 8342 + server._client.get_steps_data.assert_called_once_with("2026-05-17") + + +def test_get_steps_no_data(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_steps_data.return_value = [] + assert server.get_steps("2026-05-17") == "No steps data found for 2026-05-17" + + +def test_get_daily_stats_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_stats.return_value = { + "totalSteps": 9231, + "totalKilocalories": 2100.0, + "floorsAscended": 5, + } + data = json.loads(server.get_daily_stats("2026-05-17")) + assert data["totalSteps"] == 9231 + server._client.get_stats.assert_called_once_with("2026-05-17") + + +def test_steps_and_stats_unauthenticated(): + server._auth_state = "unauthenticated" + for fn in (server.get_steps, server.get_daily_stats): + assert "Not authenticated" in fn("2026-05-17") +``` + +- [ ] **Step 2: Run tests to verify new ones fail** + +```bash +.venv/bin/pytest tests/test_server.py::test_get_steps_success -v +``` + +Expected: `AttributeError: module 'server' has no attribute 'get_steps'` + +- [ ] **Step 3: Add steps and daily stats tools to `server.py`** + +Add the following before the `if __name__ == "__main__":` block: + +```python +@mcp.tool() +def get_steps(date: str = "") -> str: + """Get step count and daily goal for a date (YYYY-MM-DD). Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_steps_data(cdate) + if not result: + return f"No steps data found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching steps data: {exc}" + + +@mcp.tool() +def get_daily_stats(date: str = "") -> str: + """Get daily summary for a date (YYYY-MM-DD): calories, floors, intensity minutes. Defaults to today.""" + if err := _check_auth(): + return err + cdate = date or _today() + try: + result = _client.get_stats(cdate) + if not result: + return f"No daily stats found for {cdate}" + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching daily stats: {exc}" +``` + +- [ ] **Step 4: Run all tests to verify they pass** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 25 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add server.py tests/test_server.py +git commit -m "feat: add get_steps and get_daily_stats tools" +``` + +--- + +### Task 6: Gear, Devices & Profile Tools + +**Files:** +- Modify: `server.py` (add `get_devices`, `get_gear`, `get_user_profile`) +- Modify: `tests/test_server.py` (add gear/device/profile tests) + +- [ ] **Step 1: Write failing tests** + +Append to the bottom of `tests/test_server.py`: + +```python +def test_get_devices_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_devices.return_value = [ + {"deviceId": "abc123", "productDisplayName": "Forerunner 965", "softwareVersion": "21.20"} + ] + data = json.loads(server.get_devices()) + assert data[0]["productDisplayName"] == "Forerunner 965" + server._client.get_devices.assert_called_once() + + +def test_get_devices_empty(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_devices.return_value = [] + assert server.get_devices() == "No devices found." + + +def test_get_gear_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.display_name = "john.doe" + server._client.get_gear.return_value = [ + {"gearPk": "shoe1", "customMakeModel": "Nike Pegasus", "totalDistance": 320000.0} + ] + data = json.loads(server.get_gear()) + assert data[0]["customMakeModel"] == "Nike Pegasus" + server._client.get_gear.assert_called_once_with("john.doe") + + +def test_get_user_profile_success(): + server._auth_state = "authenticated" + server._client = MagicMock() + server._client.get_user_profile.return_value = { + "displayName": "john.doe", + "age": 35, + "weight": 75000.0, + } + data = json.loads(server.get_user_profile()) + assert data["displayName"] == "john.doe" + server._client.get_user_profile.assert_called_once() + + +def test_gear_devices_profile_unauthenticated(): + server._auth_state = "unauthenticated" + for fn in (server.get_devices, server.get_gear, server.get_user_profile): + assert "Not authenticated" in fn() +``` + +- [ ] **Step 2: Run tests to verify new ones fail** + +```bash +.venv/bin/pytest tests/test_server.py::test_get_devices_success -v +``` + +Expected: `AttributeError: module 'server' has no attribute 'get_devices'` + +- [ ] **Step 3: Add gear, devices, and profile tools to `server.py`** + +Add the following before the `if __name__ == "__main__":` block: + +```python +@mcp.tool() +def get_devices() -> str: + """List paired Garmin devices with model name and firmware version.""" + if err := _check_auth(): + return err + try: + result = _client.get_devices() + if not result: + return "No devices found." + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching devices: {exc}" + + +@mcp.tool() +def get_gear() -> str: + """List gear items (shoes, bikes) with mileage and usage stats.""" + if err := _check_auth(): + return err + try: + result = _client.get_gear(_client.display_name) + if not result: + return "No gear found." + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching gear: {exc}" + + +@mcp.tool() +def get_user_profile() -> str: + """Get Garmin user profile: display name, age, weight, and HR zones.""" + if err := _check_auth(): + return err + try: + result = _client.get_user_profile() + if not result: + return "No user profile found." + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching user profile: {exc}" +``` + +- [ ] **Step 4: Run all tests to verify they pass** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 30 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add server.py tests/test_server.py +git commit -m "feat: add get_devices, get_gear, and get_user_profile tools" +``` + +--- + +### Task 7: README & Claude Desktop Config + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Create `README.md`** + +```markdown +# mcp-garmin + +MCP server exposing Garmin Connect data to Claude Desktop. + +## Setup + +### 1. Install dependencies + +```bash +python -m venv .venv +.venv/bin/pip install "mcp[cli]" garminconnect +``` + +### 2. Configure Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "garmin": { + "command": "/absolute/path/to/mcp-garmin/.venv/bin/python", + "args": ["/absolute/path/to/mcp-garmin/server.py"], + "env": { + "GARMIN_EMAIL": "your@email.com", + "GARMIN_PASSWORD": "yourpassword" + } + } + } +} +``` + +Replace `/absolute/path/to/mcp-garmin` with the actual path to this directory. + +### 3. Restart Claude Desktop + +Quit and relaunch Claude Desktop to pick up the new server config. + +### 4. First-time authentication + +On first use (no cached session in `~/.garth`), ask Claude: + +> "Call authenticate()" + +If Garmin requires MFA, a prompt will appear in the chat. Check your email or phone for the code, then: + +> "Call complete_mfa with code 123456" + +Once authenticated, the session is cached in `~/.garth`. Future server restarts will log in silently without MFA. + +## Available Tools + +| Tool | Parameters | Description | +|------|-----------|-------------| +| `authenticate()` | — | Initiate Garmin login | +| `complete_mfa(code)` | `code: str` | Provide MFA code to finish login | +| `get_activities(start_date, end_date, limit)` | all optional | List recent activities | +| `get_activity_details(activity_id)` | `activity_id: str` | Full detail for one activity | +| `get_sleep(date)` | optional | Sleep score and stages | +| `get_heart_rate(date)` | optional | Resting HR and daily timeline | +| `get_stress(date)` | optional | Stress level timeline | +| `get_body_battery(date)` | optional | Body battery charge/drain | +| `get_hrv(date)` | optional | HRV status and nightly average | +| `get_spo2(date)` | optional | Blood oxygen readings | +| `get_steps(date)` | optional | Step count and daily goal | +| `get_daily_stats(date)` | optional | Calories, floors, intensity minutes | +| `get_devices()` | — | Paired Garmin devices | +| `get_gear()` | — | Gear items with mileage stats | +| `get_user_profile()` | — | Profile, age, weight, HR zones | + +All `date` parameters accept `YYYY-MM-DD` format and default to today when omitted. + +## Running Tests + +```bash +.venv/bin/pytest tests/ -v +``` +``` + +- [ ] **Step 2: Run the full test suite one final time** + +```bash +.venv/bin/pytest tests/test_server.py -v +``` + +Expected: All 30 tests pass. No warnings or errors. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add README with setup and Claude Desktop config instructions" +``` diff --git a/docs/superpowers/specs/2026-05-17-mcp-garmin-design.md b/docs/superpowers/specs/2026-05-17-mcp-garmin-design.md new file mode 100644 index 0000000..32aaa7c --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-mcp-garmin-design.md @@ -0,0 +1,126 @@ +# MCP Garmin Server — Design Spec + +**Date:** 2026-05-17 +**Status:** Approved + +--- + +## Overview + +A local MCP server that exposes Garmin Connect data to Claude Desktop via the `garminconnect` Python package. The server runs as a single-file FastMCP stdio server, authenticated via environment variables, targeting personal use with Claude Desktop as the sole client. + +--- + +## Architecture + +**Approach:** Single-file FastMCP (Option A) + +``` +mcp-garmin/ +├── server.py # FastMCP server — all tools and startup logic +├── pyproject.toml # Dependencies: mcp[cli], garminconnect +└── .env # GARMIN_EMAIL, GARMIN_PASSWORD (not committed) +``` + +`server.py` initializes a `FastMCP("garmin")` instance, registers all tools, and serves via stdio. A module-level `Garmin` client instance is shared across all tool calls — no re-authentication per call. + +**Claude Desktop config** (`~/Library/Application Support/Claude/claude_desktop_config.json`): +```json +{ + "mcpServers": { + "garmin": { + "command": "python", + "args": ["/path/to/mcp-garmin/server.py"], + "env": { + "GARMIN_EMAIL": "your@email.com", + "GARMIN_PASSWORD": "yourpassword" + } + } + } +} +``` + +--- + +## Tools + +All tools return JSON-serializable dicts. Dates are accepted as `YYYY-MM-DD` strings and default to today when omitted. + +### Authentication +| Tool | Parameters | Description | +|------|-----------|-------------| +| `authenticate()` | — | Initiates login from env vars. Returns `"Authenticated"` on success, or the MFA prompt (URL or code instruction) if Garmin requires MFA. | +| `complete_mfa(code)` | `code: str` | Accepts the MFA code and completes login, caching the session in `~/.garth`. | + +### Activities +| Tool | Parameters | Description | +|------|-----------|-------------| +| `get_activities(start_date, end_date, limit)` | `start_date: str`, `end_date: str`, `limit: int = 20` | List of activities with type, distance, duration, average HR. | +| `get_activity_details(activity_id)` | `activity_id: str` | Full detail for a single activity: splits, laps, all metrics. | + +### Health Stats +| Tool | Parameters | Description | +|------|-----------|-------------| +| `get_sleep(date)` | `date: str = today` | Sleep score, stages (deep/light/REM/awake), total duration. | +| `get_heart_rate(date)` | `date: str = today` | Resting HR and daily HR timeline. | +| `get_stress(date)` | `date: str = today` | Stress level timeline throughout the day. | +| `get_body_battery(date)` | `date: str = today` | Body battery charge/drain values throughout the day. | +| `get_hrv(date)` | `date: str = today` | HRV status and nightly average. | +| `get_spo2(date)` | `date: str = today` | Blood oxygen readings. | + +### Steps & Daily Metrics +| Tool | Parameters | Description | +|------|-----------|-------------| +| `get_steps(date)` | `date: str = today` | Step count, daily goal, and distance. | +| `get_daily_stats(date)` | `date: str = today` | Full daily summary: calories, floors, intensity minutes, active time. | + +### Gear & Devices +| Tool | Parameters | Description | +|------|-----------|-------------| +| `get_devices()` | — | List of paired Garmin devices with model and firmware version. | +| `get_gear()` | — | Gear items (shoes, bikes) with mileage/usage stats. | + +### Profile +| Tool | Parameters | Description | +|------|-----------|-------------| +| `get_user_profile()` | — | Display name, age, weight, HR zones. | + +--- + +## Authentication & Session Management + +- `garminconnect` uses `garth` under the hood, which caches OAuth tokens in `~/.garth` after first login. +- **Startup:** The server attempts a silent login using cached `~/.garth` tokens. If valid, all tools are ready immediately. If no cache exists, tools return `"Not authenticated. Ask Claude to call authenticate() first."` until auth is completed. +- **First-time setup:** The user calls `authenticate()` from Claude Desktop chat. If Garmin requires MFA, the prompt (URL or email code instruction) is returned as a string visible in the chat. The user then calls `complete_mfa(code)` with the received code. On success, garth caches the session — subsequent server restarts skip MFA entirely. +- **MFA URL handling:** If garth surfaces a URL as part of the MFA flow, it is returned verbatim in the `authenticate()` response so Claude Desktop renders it as a clickable link. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Missing `GARMIN_EMAIL` or `GARMIN_PASSWORD` env vars | Fail at startup with a clear message; do not start server | +| No cached session and no auth attempted | Tools return `"Not authenticated. Call authenticate() first."` | +| `GarminConnectAuthenticationError` | Return readable error string to the LLM | +| No data for requested date | Return `"No found for "` | +| Network error | Catch and return readable error string | + +No custom retry logic — garth handles token refresh automatically. + +--- + +## Dependencies + +- `mcp[cli]` — FastMCP framework +- `garminconnect` — Garmin Connect API wrapper (uses `garth` for auth) +- Python 3.14 (existing venv) + +--- + +## Out of Scope + +- Write operations (creating workouts, updating data) +- Multi-user support +- Hosting / remote deployment +- GPS track/polyline data (raw GPX export) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..95cd55b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "mcp-garmin" +version = "0.1.0" +description = "MCP server for Garmin Connect" +requires-python = ">=3.11" +dependencies = [ + "mcp[cli]", + "garminconnect", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build"