Skip to main content
Glama
pluton74mac

garmin-mcp-triathlon

by pluton74mac

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


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.

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

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

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 reached the same conclusion independently, by its own live round trip.


Related MCP server: GC-MCP

Quick Start (Hermes Agent)

git clone https://github.com/pluton74mac/Garmin_MCP_Triathlon.git
cd Garmin_MCP_Triathlon

One-Command Setup

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

./scripts/hermes-setup.sh --profile triathlon-coach

Manual Setup (if you prefer step-by-step)

1. Install uv standalone

# Required — pip-installed uv is NOT in Hermes' PATH
curl -LsSf https://astral.sh/uv/install.sh | sh

2. Authenticate

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

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:

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.

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.


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 records what each one encoded and why.

The judgement lives in 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

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

"env": {
  "GARMIN_ENABLED_TOOLS": "get_morning_brief,get_sleep_data,get_training_readiness_composite,..."
}

Testing

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

  • 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)

Both fixes are carried here regardless of whether upstream merges them.


Upstream Setup (Claude Desktop, Codex, Docker)

See upstream documentation for:

  • Claude Desktop configuration

  • Codex/opencode TOML config

  • Docker deployment

  • HTTP transport mode

  • Garmin Connect China


Credits

License

MIT (same as upstream)

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • F
    license
    B
    quality
    D
    maintenance
    Integrates with Garmin Connect to retrieve activity data, health metrics, and provide AI-powered training insights and personalized coaching recommendations based on your fitness activities and performance trends.
    14
  • F
    license
    -
    quality
    A
    maintenance
    Enables AI assistants to access and query Garmin Connect health and fitness data, including sleep, HRV, training load, and activities, with an optional coaching plugin for personalized training plans.
    10
    4
  • F
    license
    -
    quality
    B
    maintenance
    Exposes Garmin Connect data and workout management to AI agents, supporting tools, resources, and prompts for health data, workout creation, and coaching workflows.
    1

View all related MCP servers

Related MCP Connectors

  • List, fetch, create, edit (replace), delete and schedule structured workouts on Garmin Connect (runn

  • Garmin data in Claude & ChatGPT via the Garmin Health API. OAuth sign-in, no password sharing.

  • Garmin data in Claude: 135 tools — activities, sleep, HRV, training, workouts. Free, open source.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pluton74mac/Garmin_MCP_Triathlon'

If you have feedback or need assistance with the MCP directory API, please join our Discord server