garmin-mcp-triathlon
by pluton74mac
README.md
# Garmin_MCP_Triathlon
*Installs two commands: `garmin-mcp-triathlon` (the server your MCP client launches) and `garmin-mcp-triathlon-auth` (one-time Garmin authentication).*
> **Fork of [Taxuspt/garmin_mcp](https://github.com/Taxuspt/garmin_mcp)** (749★, MIT) — a Garmin Connect MCP server purpose-built for triathlon & endurance coaching.
**171 tools in total.** 140 come from upstream — Garmin health data, activities, workouts, devices, gear and the rest. **31 are new**: 18 triathlon workout builders and 13 coaching tools across analytics, composite views, bulk data retrieval and plan automation.
The coaching tools return **measurements only**. Thresholds, verdicts and recommendations live in a separate coaching skill — see [Design Principle](#design-principle).
---
## What's Different from Upstream
### 31 New Tools
| Module | Tools | What They Do |
|--------|-------|-------------|
| **Workout Builders** | 18 | Cycling, running, swimming, brick/multi-sport and strength — natural params → verified Garmin JSON → upload. (The module holds 23; five are upstream builders kept as-is, listed below.) |
| **Bulk Data** | 3 | `get_health_series` (7 metrics × N days, one call), `get_activity_series`, `get_athlete_context` (LTHR, FTP, HR zone floors) |
| **Coaching Analytics** | 7 | Readiness score + factors, load breakdown, zone distribution, scheduled-vs-completed pairing, performance trend, cardiac drift, weekly minutes |
| **Composite Views** | 2 | Morning brief (5 endpoints in one round trip), athlete status snapshot with baseline deviations |
| **Plan Execution** | 1 | Weekly plan creator — YAML → built, uploaded and scheduled on the Garmin calendar |
Every one of them returns measurements. What the numbers *mean* is the
coaching skill's job — see [Design Principle](#design-principle).
### 3 Critical Mapping Bugs Fixed
The upstream `workout_builders.py` and the old `json_encoder.py` had silent bugs. Our builder tools fix them:
| Target | Bug (Old) | Fix (Our Fork) | Device Display |
|--------|----------|----------------|----------------|
| Exact cycling watts | ID 6 `power.between` → Garmin stores it as `pace.zone` | ID 2 `power.zone` + `targetValueOne/Two` | **"240-270W"** not a pace target |
| Custom HR range (e.g. 130-145 bpm) | Silent drop (`.heart.rate` key crash) | ID 4 `heart.rate.zone` + `targetValueOne/Two` | **"130-145 bpm"** not blank |
| No validation pipeline | Raw JSON, silent upload failures | All builders go through `upload_workout` validation | Catches errors before upload |
> **On cycling watt targets.** Earlier versions of this table recommended
> target ID `6` with `power.between`, following upstream's docstrings. A live
> upload/read-back against a real account shows Garmin silently rewrites that
> to `pace.zone` on a cycling workout — the watt bounds survive but are
> reinterpreted as pace. Target ID `2` (`power.zone`) with the bounds in
> `targetValueOne`/`targetValueTwo` round trips intact and is what the
> builders now emit.
**Every builder passes integration tests**, and the coaching tools are
verified against a live Garmin account — mocks alone hid several payload
shape bugs (see `normalize_sleep`, `normalize_readiness`).
### Verified on device
Uploaded from these builders, synced to an Instinct 2X Solar, and read off the
watch. Anything not in this table is verified against the API only.
| Target | Encoding | Watch shows |
|--------|----------|-------------|
| Cycling watts | ID 2 `power.zone` + `targetValueOne/Two` | `240-270W` |
| Cycling power zone | ID 2 `power.zone` + `zoneNumber` | `Pwr. Zone 3` |
| Custom HR range | ID 4 `heart.rate.zone` + `targetValueOne/Two` | `130-145 bpm` |
| Named HR zone | ID 4 `heart.rate.zone` + `zoneNumber` | `HR zone 4` |
| Repeat groups | `RepeatGroupDTO` + `numberOfIterations` | `4×`, recovery `1:30` |
| Swim pace | ID 6 `pace.zone`, bounds in m/s | `1:20/100m` |
| Swim pace band | ID 6 `pace.zone`, two bounds | `1:15-1:25/100m` |
The one that does **not** work: ID 6 `power.between` on a cycling workout. It
uploads without error and reads back with the watt bounds intact, but Garmin
stores it as `pace.zone` and the watch renders `240` m/s as **`864.00km/h`**.
That is what motivated the ID 2 correction above — see
[upstream issue #245](https://github.com/Taxuspt/garmin_mcp/issues/245).
**`power.between` is now rejected on upload**, whatever `workoutTargetTypeId`
it arrives with, and the error names `power.zone` as the replacement. Garmin
treats the id as authoritative and ignores the key string, so a wrong pairing
cannot be caught by an id/key cross-check — the key has to be refused by name.
Accepting it for backwards compatibility only preserved a silent wrong answer.
[Upstream PR #194](https://github.com/Taxuspt/garmin_mcp/pull/194) reached the
same conclusion independently, by its own live round trip.
---
## Quick Start (Hermes Agent)
```bash
git clone https://github.com/pluton74mac/Garmin_MCP_Triathlon.git
cd Garmin_MCP_Triathlon
```
### One-Command Setup
```bash
./scripts/hermes-setup.sh
```
This handles everything: installs `uv` standalone (required — pip-installed won't work), creates the wrapper script, and writes the MCP config to `~/.hermes/config.yaml`. Then type `/reload-mcp` in your Hermes chat.
If you run garmin-mcp-triathlon under a dedicated Hermes profile rather than the default one, pass `--profile <name>` so the config is written to `~/.hermes/profiles/<name>/config.yaml` instead of the global config:
```bash
./scripts/hermes-setup.sh --profile triathlon-coach
```
### Manual Setup (if you prefer step-by-step)
#### 1. Install uv standalone
```bash
# Required — pip-installed uv is NOT in Hermes' PATH
curl -LsSf https://astral.sh/uv/install.sh | sh
```
#### 2. Authenticate
```bash
cd Garmin_MCP_Triathlon
uv run garmin-mcp-triathlon-auth
```
Enter your Garmin email, password, and MFA code. Tokens saved to `~/.garminconnect/`.
#### 3. Create wrapper script
```bash
REPO_DIR="$(cd Garmin_MCP_Triathlon && pwd)" # absolute path to your clone
cat > ~/.local/bin/garmin-mcp-triathlon << EOF
#!/usr/bin/env bash
cd "$REPO_DIR"
exec "\$HOME/.local/bin/uv" run garmin-mcp-triathlon
EOF
chmod +x ~/.local/bin/garmin-mcp-triathlon
```
Use the actual absolute path to your clone here, not a placeholder — a wrapper that can't resolve `REPO_DIR` will silently no-op the `cd` and `uv run` will fail to find `pyproject.toml`.
**Why a wrapper?** Hermes Agent may not parse `command` + `args` arrays in `config.yaml` correctly — it can spawn the server *name* as the command instead of `uv`. The wrapper bundles the `cd` + `exec uv run` into one executable, bypassing this bug entirely.
#### 4. Configure Hermes
Write the MCP config with Python YAML (Hermes guards `config.yaml` from file tools). Target the global config only if you're not using a dedicated Hermes profile — if you are, write to `~/.hermes/profiles/<name>/config.yaml` instead, or the global default profile gets silently reconfigured:
```python
import os
import yaml
p = '~/.hermes/config.yaml' # or ~/.hermes/profiles/<name>/config.yaml for a dedicated profile
p = os.path.expanduser(p)
with open(p) as f:
c = yaml.safe_load(f)
c['mcp_servers']['garmin-mcp-triathlon'] = {
'command': os.path.expanduser('~/.local/bin/garmin-mcp-triathlon'),
'timeout': 300 # cold start: Garmin auth takes 10-15s
}
with open(p, 'w') as f:
yaml.safe_dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
```
#### 5. Load Tools
In a Hermes chat session: `/reload-mcp`
Verify with: `get_user_profile` — should return your Garmin profile.
### Common Pitfalls
| Symptom | Cause | Fix |
|---------|-------|-----|
| `Failed to spawn: garmin-mcp-triathlon` | Hermes misparsing `command`+`args` | Use wrapper script (Step 3) |
| `Connection closed` in `hermes mcp test` | Cold-start timeout (Garmin auth takes 10-15s) | Bump `timeout` to 300, retry |
| `uv: command not found` | pip-installed uv, not standalone | Install standalone (Step 1) |
| Other MCP servers disappeared | Overwriting `mcp_servers` block | Use Python YAML to merge, not replace |
| Tools not visible after `/reload-mcp` | Config cached or parsing error | Verify with `hermes mcp list` and `hermes mcp test` |
| Nutrition tools return `403 Forbidden` | Garmin nutrition/food-log API not enabled for the account | Account-level, not a bug here — reads and writes both fail before this code runs |
| Brick workout says *not compatible* on the watch | Device does not support multi-sport structured workouts | See the note under Brick / Multi-Sport builders |
| A workout step shows no target on the watch | Step list often omits it | Press into the step — the target is usually there |
---
## Workout Builder Catalog
### Cycling (6 builders)
```
create_cycling_endurance_workout(name, duration_min, hr_zone="Z2", warmup_min=15, cooldown_min=15)
create_cycling_tempo_workout(name, duration_min, hr_zone="Z3", warmup_min=15, cooldown_min=15)
create_cycling_sweet_spot_workout(name, reps=3, work_min=20, rest_min=5, warmup_min=15, cooldown_min=10)
create_cycling_interval_workout(name, reps=5, work_sec=180, rest_sec=180, power_low=250, power_high=270, ...)
create_cycling_over_under_workout(name, reps=3, over_sec=60, under_sec=120, over_pct=105, under_pct=90, ...)
create_cycling_ftp_test_workout(name="FTP Test", warmup_min=20, test_min=20, cooldown_min=15)
```
### Running (6 builders)
```
create_run_easy_workout(name, duration_min, hr_zone="Z2", warmup_min=10, cooldown_min=10)
create_run_tempo_workout(name, duration_min, hr_zone="Z4", warmup_min=10, cooldown_min=10)
create_run_long_workout(name, duration_min, hr_min=130, hr_max=145, ...) # custom BPM range!
create_run_intervals_workout(name, reps=6, distance_m=400, rest_sec=120, hr_zone="Z5", ...)
create_run_hills_workout(name, reps=8, hill_sec=60, jog_down_sec=90, ...)
create_run_progression_workout(name, blocks=[...], warmup_min=15, cooldown_min=10)
```
### Swimming (4 builders)
```
create_swim_endurance_workout(name, distance_m=1500, pace="1:45/100m", stroke="freestyle", pool_length=25)
create_swim_intervals_workout(name, reps=4, distance_m=200, rest_sec=30, pace="1:40/100m", ...)
create_swim_threshold_workout(name, distance_m=800, pace="1:42/100m", ...)
create_swim_drills_workout(name, drills=[{name, distance_m, equipment, stroke}, ...], ...)
```
### Brick / Multi-Sport (2 builders)
```
create_brick_bike_run_workout(name, bike_duration_min=60, run_duration_min=20, bike_hr_zone="Z2", run_hr_zone="Z3")
create_brick_swim_bike_workout(name, swim_distance_m=1500, bike_duration_min=60, swim_pace="1:45/100m", ...)
```
> **Check your watch supports multi-sport workouts before relying on these.**
> Both builders upload valid `multi_sport` workouts, but many Garmin watches
> cannot run a structured multi-sport workout and will report the workout as
> *not compatible* when you try to send it to the device. Confirmed on an
> Instinct 2X Solar and a Forerunner 245 Music — and a multi-sport workout
> created natively in the Garmin Connect app is rejected identically, so this
> is a device limitation rather than an encoding fault. Multi-sport structured
> workouts are generally a higher-tier feature (Forerunner 745/945/955/965,
> Fenix 6 and later, Enduro).
### Upstream builders (preserved)
`create_walk_run_workout`, `create_z2_walk_workout`, `create_strength_workout`, `create_run_workout`, `upload_workout`, `schedule_week`
---
## Coaching Analytics Catalog
All 13 coaching tools, and only the 13 that exist. Each returns measurements;
none returns a verdict. Thresholds live in the coaching skill — see
[Design Principle](#design-principle).
### Bulk Data (3 tools)
| Tool | Returns |
|------|---------|
| `get_health_series(start, end, metrics=None)` | Per-day body battery (4 values), HRV, resting HR, sleep, stress, training load, readiness — plus `errors[]`, `api_calls` and a `rate_limited` flag |
| `get_activity_series(start, end)` | Per-activity date, sport, duration, distance, HR, power, training effect, optional HR-zone seconds |
| `get_athlete_context()` | LTHR, cycling/running FTP with `as_of` + `is_stale`, per-sport HR zone floors, VO2max, physical data, preferences, `not_available` |
### Individual Analytics (7 tools)
| Tool | Returns |
|------|---------|
| `get_training_readiness_composite(date)` | Garmin's readiness score, its level, six factor percentages |
| `get_training_load_breakdown(start, end)` | Minutes per sport plus Garmin's acute/chronic load, ACWR and TSB |
| `get_zone_distribution(start, end)` | Seconds per HR zone as percentages, by sport |
| `get_workout_compliance(start, end)` | Scheduled workouts paired with same-day activities |
| `get_performance_trend(metric, sport, days)` | Per-activity pace or power + avg HR, regression slope |
| `get_cardiac_drift_analysis(activity_id)` | `hr_drift_pct` — needs power and ≥60 min at 1 s sampling |
| `get_weekly_load_progression(weeks=12)` | Minutes per ISO week, week-over-week change |
### Composite Views (2 tools)
| Tool | Impact |
|------|--------|
| `get_morning_brief(date)` | **5 calls → 1** — sleep, recovery, readiness, today's workout |
| `get_athlete_status_snapshot(date)` | Current values, Garmin baselines, deviations |
### Plan Automation (1 tool)
| Tool | What It Does |
|------|-------------|
| `create_weekly_plan(plan_yaml_path)` | Reads YAML/JSON → creates all workouts → schedules each on its own `date` in the Garmin Calendar |
### Nine tools were removed, not relocated
`run_safety_check`, `check_overtraining_risk`, `get_injury_risk_assessment`,
`get_reds_risk_assessment`, `get_load_adjustment_recommendation`,
`get_recovery_trend`, `get_weekly_health_summary`, `generate_taper_plan` and
`validate_weekly_plan` no longer exist, and neither does
`src/garmin_mcp/coaching_safety.py`. Each of them encoded a coaching judgement
— a threshold, a load curve, a gate — inside the data layer. Two of them
returned **opposite verdicts on identical data**. That reasoning now lives in
`skills/triathlon-coaching/`, where every threshold is one line of
`rules.yaml` with its provenance recorded.
If you are looking for a safety gate, injury screen or taper, it is in the
skill, not here. See [`docs/coaching-split-audit.md`](docs/coaching-split-audit.md).
---
## Architecture
```
Garmin_MCP_Triathlon/
├── src/garmin_mcp/ # Preserved upstream namespace
│ ├── *.py # Upstream modules (UNCHANGED)
│ ├── workout_builders.py # EXTENDED: +18 triathlon builders
│ │
│ ├── coaching_data.py # NEW: 3 bulk retrieval tools
│ ├── coaching_analytics.py # NEW: 7 measurement tools
│ ├── coaching_composite.py # NEW: 2 aggregated view tools
│ └── coaching_planning.py # NEW: 1 plan execution tool
│
├── skills/triathlon-coaching/ # NEW: the judgement layer
│ ├── rules.yaml # every threshold, one file
│ ├── scripts/evaluate.py # contains no numbers
│ └── references/ # provenance for each threshold
│
├── tests/
│ ├── unit/ # Unit tests for builders
│ ├── integration/ # Integration tests (mocked Garmin API)
│ │ ├── test_workout_builders_tools.py # EXTENDED
│ │ ├── test_coaching_data_tools.py # NEW
│ │ ├── test_thinned_surface.py # NEW: no verdicts leak
│ │ ├── test_fetch_failures_surface.py # NEW
│ │ └── test_*_reads.py # NEW: payload-shape guards
│ └── e2e/ # End-to-end (real Garmin creds)
```
Every new module follows the upstream pattern: `configure(client)` + `register_tools(app)`.
### Design Principle
```
┌────────────────────────────────────────┐
│ garmin-mcp-triathlon (DATA LAYER) │
│ "What does the data say?" │
│ Raw Garmin data → structured JSON │
└────────────┬───────────────────────────┘
│ MCP tool calls
▼
┌────────────────────────────────────────┐
│ Hermes Coaching Skills (INTELLIGENCE) │
│ "What should we do about it?" │
│ Interpret, recommend, plan │
└────────────────────────────────────────┘
```
The MCP returns data. The coach decides what to do.
**This is enforced, not aspirational.** No coaching tool returns a threshold,
a severity, a gate or a sentence of advice; a test walks every tool's output
looking for that vocabulary. Nine tools that did were removed and seven were
thinned — [`docs/coaching-split-audit.md`](docs/coaching-split-audit.md)
records what each one encoded and why.
The judgement lives in [`skills/triathlon-coaching/`](skills/triathlon-coaching/),
where every threshold sits in one editable `rules.yaml` and the evaluator
contains no numbers at all. `references/rationale.md` records where each
number came from and what live data says about it.
Two rules the data layer keeps:
- **A failed fetch is never silence.** Every tool that walks a date range
returns an `errors[]` array. A day with no data is absent from the results;
a day whose request raised is in `errors`. Collapsing those two is how an
expired token used to produce a confident all-clear.
- **Nothing is substituted for a missing reading.** No zeros, no plausible
defaults. An absent measurement is absent.
---
## The `errors[]` contract
Every tool that walks a date range or a list of activities returns an errors
array. It is part of the tool's output contract, not a debugging aid, and the
coaching skill depends on it.
### Schema
```json
{"date": "2026-08-02", "metric": "hrv", "error": "429 Too Many Requests"}
```
| Field | Type | Meaning |
|---|---|---|
| `date` | `YYYY-MM-DD` | the day whose request failed |
| `metric` | string | the **metric** that was lost, never the endpoint |
| `error` | string | the exception text, unedited |
`get_activity_series` adds `activity_id` for per-activity failures (HR-zone
lookups) and omits `date` when the failure is not day-scoped.
`get_athlete_context` uses `{"source": ..., "error": ...}` — its calls are not
per-day.
### The three states it exists to separate
| State | results | `errors` |
|---|---|---|
| Everything worked | full | `[]` |
| Athlete has genuine gaps — watch not worn | short | `[]` |
| Fetch failed — expired token, 429, outage | short | populated |
Rows two and three are **byte-identical in the results**. Without `errors`
they are indistinguishable, and that is precisely how an expired token used to
produce a confident all-clear from the safety gate.
### Rules
1. **A day with no data is absent from the results. A day whose request raised
is in `errors`.** Never both, never neither.
2. **`metric`, not endpoint.** `body_battery` and `stress` share `get_stats`;
when that call fails, both metric names appear. A caller should not have to
know Garmin's endpoint topology to understand what it just lost.
3. **Nothing is substituted.** No zeros, no plausible defaults, no
backfilling a missing value from a neighbouring field.
4. **A non-empty `errors` adds a `warning` string** saying in prose that the
gap is not a negative finding. The consumer is usually a language model,
and a sentence is harder to skip than an integer.
5. **`api_calls` reports the real cost**, so the price of a wide date range is
visible rather than inferred.
6. **A rate limit aborts the walk.** A 429 sets `rate_limited: true` and stops
immediately rather than working through the rest of the range. See below.
### Rate limiting
Garmin publishes no limits for this API. What is known from the community is
that the aggressive limiting sits on the **login/SSO** endpoints and is keyed
**per account** — not per IP or user agent — with reported blocks lasting from
about an hour to 48+ hours. Token-based auth keeps this server off that path
almost entirely: it resumes from `~/.garminconnect/` rather than signing in.
`garminconnect` 0.3.2 paces and retries **login only** — both of its anti-WAF
sleeps live inside the SSO functions. **Data calls have no backoff whatsoever;
a 429 raises straight through.** A 60-day, 6-source `get_health_series` walk is
roughly 360 unpaced requests, so on hitting a limit the walk stops at the first
refusal instead of firing hundreds more. Days already retrieved are returned
and are complete; everything after the stop is unknown, and the warning says
so.
If you see `rate_limited: true`, wait before retrying and ask for a shorter
range or fewer metrics. Do not loop.
### For consumers
**Never draw a negative conclusion from a short result set while `errors` is
non-empty.** "No overtraining signals" and "we could not look" are different
statements. The coaching skill turns its safety gate to `unknown` — never
`green` — whenever `errors` is populated, and a real trigger still outranks it
so a red gate is not downgraded by an unrelated 429.
### Emitted by
`get_health_series`, `get_activity_series`, `get_athlete_context`,
`get_morning_brief` and `get_athlete_status_snapshot` (the last two as
`fetch_errors`, since they are single-date tools rather than range walks).
---
## Garmin payload shapes worth knowing
These cost real debugging time. Each was found by reading a **live** payload,
never by inferring from a plausible key name — and each one, before it was
found, produced a confidently wrong number rather than an error. Most are
handled by a named helper (in `coaching_analytics.py` unless noted); use the
helper rather than reading the field directly.
| Endpoint | Actual shape | Helper |
|----------|--------------|--------|
| `get_sleep_data` | summary nested under `dailySleepDTO`; `sleepScores.overall` is a dict with `.value` | `normalize_sleep`, `sleep_score`, `sleep_hours` |
| `get_training_readiness` | one-element **list**; no `maxPossible`; factors are `<thing>FactorPercent` | `normalize_readiness`, `readiness_level` |
| `get_rhr_day` | `allMetrics.metricsMap.WELLNESS_RESTING_HEART_RATE[].value`, not a flat `restingHeartRate` | `extract_resting_hr` |
| `get_training_status` | `acuteTrainingLoadDTO` under `mostRecentTrainingStatus.latestTrainingStatusData.<deviceId>` | `_extract_acute_load_dto` |
| `get_stats` | `bodyBatteryMostRecentValue` is the **end-of-day drain**, not the overnight charge | `body_battery_at_wake` |
| `get_hrv_data` | the seven-day figure is `weeklyAvg`; there is no `lastSevenDaysAvg` | read `weeklyAvg` |
| `download_activity` | takes an `ActivityDownloadFormat` enum with **no FIT member** — the FIT arrives inside the `ORIGINAL` zip | `_extract_fit_bytes` (`activity_analysis.py`) |
| `get_max_metrics` | returns `[]` on some accounts; VO2max is in `get_user_profile().userData` | — |
| HR zone floors | live at `/biometric-service/heartRateZones`, which `garminconnect` does not wrap | raw `connectapi` |
| `get_activity` | no `hrInTimezones` — HR zones come from `get_activity_hr_in_timezones`, one call per activity | — |
| `get_body_battery_events` | event series, not a daily summary; use `get_stats` | — |
| `get_activities_by_date` | list, **newest first** — sort before treating position as time | `sport_family` |
| `workoutScheduleSummariesScalar` | a JSON **scalar** taking `Date` args; sub-selecting fields is rejected | `fetch_scheduled_workouts` |
**Sport keys must be enumerated explicitly.** `activityType.parentTypeId` is
not a usable grouping key — `17` is shared by running, cycling, hiking and
walking, while `trail_running` reports `1` and `road_biking` reports `2`. The
discipline lists live in `SPORT_TYPE_KEYS`; note that outdoor rides are
`road_biking`, not `cycling`, and open water is `open_water_swimming`. Missing
those two silently dropped activities from load, zone and injury analysis.
**`pace.zone` bounds are metres per second.** For swim paces use
`_pace_to_mps` (`100 / seconds_per_100m`). Inverting this is easy to miss
because the common default `1:40/100m` is exactly 100 s — the one value where
the correct and inverted expressions agree.
**Multi-segment workouts need workout-unique `stepOrder`.** Restarting at 1 per
segment makes Garmin reject the upload outright; `_renumber_steps_across_segments`
numbers continuously and descends into repeat groups.
---
## Tool Filtering
171 tools is a lot of context. Filter per skill with `GARMIN_ENABLED_TOOLS`:
| Skill | Enable these |
|-------|-------------------|
| Health Dashboard | `get_stats`, `get_sleep_data`, `get_hrv_data`, `get_body_battery`, `get_stress_data`, `get_training_readiness_composite`, `get_morning_brief` |
| Workout Review | `get_activity_splits`, `get_activity`, `get_training_effect`, `get_activity_fit_data`, `get_cardiac_drift_analysis` |
| Workout Manager | All `create_*_workout` builders + `schedule_week` + `create_weekly_plan` |
| Weekly Insights | `get_zone_distribution`, `get_workout_compliance`, `get_training_load_breakdown`, `get_performance_trend`, `get_weekly_load_progression` |
| Coaching Skill | `get_health_series`, `get_activity_series`, `get_athlete_context` — the three bulk tools are all the skill's evaluator needs |
Names are checked at startup: anything in `GARMIN_ENABLED_TOOLS` that matches
no registered tool is reported on stderr rather than silently ignored.
Set via MCP server env:
```json
"env": {
"GARMIN_ENABLED_TOOLS": "get_morning_brief,get_sleep_data,get_training_readiness_composite,..."
}
```
---
## Testing
```bash
# All tests (unit + integration) — 634 tests
uv run pytest tests/unit/ tests/integration/ -v
# Specific module
uv run pytest tests/integration/test_workout_builders_tools.py -v
# End-to-end (requires real Garmin credentials)
uv run pytest tests/e2e/ -m e2e -v
```
**634 tests pass** across `tests/unit` and `tests/integration`; 664 including the coaching skill's own suite (`pytest -m "not e2e"`). `pytest -m e2e` is 10 passed, 6 skipped — the skips are the nutrition tests, gated on a live probe because that API is 403 on accounts without the feature. Zero regressions on upstream tests.
### The mock is specced against the real client
`tests/conftest.py` builds the Garmin client with `create_autospec` against a
real `Garmin` instance, so a call with the wrong arity — or to a method that
does not exist — fails immediately. This matters: an earlier revision used a
bare `Mock()`, which accepts anything, and the suite was fully green while
seven tools were calling the API incorrectly and failing on every invocation.
Two rules when extending the fixtures:
- Spec against an **instance**, not the class. `Garmin.__init__` assigns
`.client` and the `garmin_connect_*` URLs, which a class-level autospec
cannot see.
- Set defaults with `client.method.return_value = ...`, never
`client.method = Mock(...)` — the latter replaces the autospec'd child and
silently discards signature checking.
### Autospec is necessary but not sufficient
Autospec constrains *call shapes*; it says nothing about whether the payload
you assert on matches what Garmin actually returns. Several bugs survived a
green suite because the fixtures encoded shapes the API does not produce —
sleep summaries nested under `dailySleepDTO`, training readiness returned as a
one-element list, HR zones served from a separate endpoint. Fixtures in this
repo are kept faithful to live payloads for that reason.
### Manual Display Test (Required for Builders)
Uploading successfully is not the same as displaying correctly — Garmin
silently rewrites some targets on save. To verify:
1. Call a builder via MCP (e.g. `create_cycling_interval_workout`)
2. Open Garmin Connect → Workouts → verify name, sport, steps, targets
3. Sync to device → start workout → **press into each step** to see its target;
the step list alone often does not show it
4. Confirm the target reads in the units you asked for (watts, bpm, min/100m)
---
## Critical Mapping Reference
| Target Type | Correct ID | Correct Key | Extra Fields |
|------------|-----------|-------------|-------------|
| Exact power (watts) | 2 | `power.zone` | `targetValueOne`=high, `targetValueTwo`=low |
| Power zone (FTP%) | 2 | `power.zone` | `zoneNumber`=1-7 |
| HR zone (named) | 4 | `heart.rate.zone` | `zoneNumber`=1-5 |
| HR custom (BPM) | 4 | `heart.rate.zone` | `targetValueOne`=low, `targetValueTwo`=high |
| Pace zone | 6 | `pace.zone` | `targetValueOne`=max m/s, `targetValueTwo`=min m/s |
Always set BOTH `workoutTargetTypeId` AND `workoutTargetTypeKey` — the validation pipeline catches mismatches.
---
## Upstream Features Preserved
**171 tools total** once the coaching modules are registered — counted from
the `@app.tool()` registrations, with no duplicate names. The upstream surface
is preserved in full:
| Module | Tools | |
|---|---|---|
| `health_wellness` | 29 | sleep, HRV, body battery, stress, respiration, steps |
| `activity_management` | 22 | list, get, edit, rename, retype, manual entry, delete |
| `training` | 15 | CTL/ATL/TSB, HRV trend, VO2 max, FTP, lactate threshold |
| `workouts` | 14 | upload, schedule, unschedule, delete, list, download |
| `nutrition` | 14 | food log, custom foods, meals, hydration targets |
| `challenges` | 9 | badges, ad-hoc and virtual challenges |
| `devices` | 6 | device list, settings, solar data, alarms |
| `weight_management` | 5 | weigh-ins by day and range, add, delete |
| `user_profile` | 4 | profile, settings, personal records |
| `activity_analysis` | 4 | FIT parsing, power duration curve, Di2 shift summary |
| `womens_health` | 3 | menstrual cycle and pregnancy data |
| `gear_management` | 3 | gear list with stats, associate/dissociate per activity |
| `data_management` | 4 | body composition, blood pressure (add + delete), hydration |
| `courses` | 3 | list, upload GPX, delete |
That is 135 tools, plus the 5 upstream workout builders kept inside
`workout_builders.py` (`create_walk_run_workout`, `create_run_workout`,
`create_z2_walk_workout`, `create_strength_workout`, `schedule_week`) — **140
upstream-derived**. The remaining **31 are new**: 18 triathlon builders and
13 coaching tools.
`delete_activity` and `delete_blood_pressure` were added so that
`create_manual_activity` and `set_blood_pressure` are undoable through the
server — `garminconnect` had both deletes and neither was registered.
Note that `nutrition` returns **HTTP 403 on accounts without Garmin's
nutrition feature**, reads and writes alike, before any of this code runs.
That is account-level, not a defect here.
Synced with upstream through `a16f057`, which adds `search_foods`,
`set_nutrition_daily_settings` and Garmin Coach workout access, and carries
upstream's DXT, stdio-corruption and nested-target-bounds fixes.
Two upstream defects were found here and submitted back:
- **`get_device_solar_data`** read six fields that do not exist in the
response, so it reported no data for solar watches that had a full day of
readings. It now reads `solarDailyDataDTOs[].localConnectDate` and derives
utilisation from `solarInputReadings[]`. Verified against an Instinct 2X
Solar with 1254 readings.
([upstream PR #247](https://github.com/Taxuspt/garmin_mcp/pull/247))
- **`get_endurance_score`** crashed on Garmin's explicit `null` for a section
with no data — `.get("enduranceScoreDTO", {})` does not help when the key is
present and the value is `None`.
([upstream PR #246](https://github.com/Taxuspt/garmin_mcp/pull/246))
Both fixes are carried here regardless of whether upstream merges them.
---
## Upstream Setup (Claude Desktop, Codex, Docker)
See [upstream documentation](https://github.com/Taxuspt/garmin_mcp) for:
- Claude Desktop configuration
- Codex/opencode TOML config
- Docker deployment
- HTTP transport mode
- Garmin Connect China
---
## Credits
- **Upstream:** [Taxuspt/garmin_mcp](https://github.com/Taxuspt/garmin_mcp) — the Garmin MCP server this is forked from (749★, MIT license)
- **Garmin API:** [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) by cyberjunky
- **Fork:** [pluton74mac/Garmin_MCP_Triathlon](https://github.com/pluton74mac/Garmin_MCP_Triathlon) — 13 coaching tools, 18 new triathlon workout builders, and the coaching skill
## License
MIT (same as upstream)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing