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

30 KiB

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

[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
.venv/bin/pip install "mcp[cli]" garminconnect pytest

Expected: All packages install without errors. Verify with:

.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
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:

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
.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
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
.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
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):

from datetime import date
from garminconnect import GarminConnectAuthenticationError

Then append to the bottom of tests/test_server.py:

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
.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:

@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
.venv/bin/pytest tests/test_server.py -v

Expected: All 13 tests pass.

  • Step 5: Commit
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:

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
.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:

@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
.venv/bin/pytest tests/test_server.py -v

Expected: All 21 tests pass.

  • Step 5: Commit
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:

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
.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:

@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
.venv/bin/pytest tests/test_server.py -v

Expected: All 25 tests pass.

  • Step 5: Commit
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:

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
.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:

@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
.venv/bin/pytest tests/test_server.py -v

Expected: All 30 tests pass.

  • Step 5: Commit
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

# 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:

{
  "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

.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
git add README.md
git commit -m "docs: add README with setup and Claude Desktop config instructions"