garmin-mcp-triathlon

garmin-mcp-triathlon

Enables triathlon coaches and athletes to interact with Garmin Connect, including retrieving health/activity data, building and uploading structured workouts (cycling, running, swimming, brick), and accessing coaching analytics like readiness, load, and performance trends.

Category
访问服务器

README

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.


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)

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选