Files
mcp-garmin/docs/superpowers/plans/2026-05-17-mcp-garmin.md
2026-05-17 19:37:05 +02:00

1006 lines
30 KiB
Markdown

# 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"
```