garmin-mcp-triathlon
# 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_activity_details`, `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` | 23 | list, get, GPS track, 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)
TDQS
Scored across 172 tools
With 172 tools there is heavy overlap: get_activities_fordate / get_activities_by_date / get_activities / count_activities, get_stats / get_user_summary / get_stats_and_body, and a large family of run-workout builders (create_run_workout vs create_run_easy_workout/tempo/long/intervals/hills/progression) that differ only by defaults. The very detailed descriptions and explicit 'use X instead' notes (e.g. summary vs full variants, the get_garmin_coach_workouts alias) mitigate much of the confusion, but several boundaries remain fuzzy.
Almost everything follows a snake_case verb_noun pattern (get_*, set_*, create_*, delete_*, upload_*, schedule_*, log_*, search_*), which is highly predictable. Minor deviations exist: get_activities_fordate lacks the underscore used in get_activities_by_date, singular/plural varies (get_personal_record vs get_race_predictions), and upsert_and_log uses a non-standard verb.
172 tools is far beyond any reasonable agent-facing surface and reflects extreme specialization, redundant aliases (schedule_workout/schedule_workouts/schedule_week, upload_workout/upload_workouts), and near-duplicate summary/full pairs. This is the extreme-mismatch end of the scale even for a broad platform like Garmin Connect.
Coverage is remarkably broad: activity read/update/delete/create, health and sleep metrics, nutrition CRUD plus logging, workout build/upload/schedule/unschedule/delete, courses, gear, challenges and rich training analytics. Only minor gaps remain (no workout edit, no true hydration delete, no update for scheduled entries), and several are documented platform limitations the agent can work around.