ClaudeCyclingMCP
Generates Garmin Connect workout payloads from a structured cycling session spec, including power targets, cadence, and step types, ready for upload via the Garmin MCP's upload_workout tool.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ClaudeCyclingMCPRender this cycling workout spec to MyWhoosh .zwo and Garmin files"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
ClaudeCyclingMCP
Describe a structured cycling session once. Get a valid MyWhoosh .zwo and a
valid Garmin Connect workout out of it.
spec (JSON) ──┬── render_zwo → .zwo (MyWhoosh)
└── render_garmin → .json (Garmin upload_workout payload)Why this exists
Writing a structured bike session today means either clicking blocks around in a graphical editor — slow, imprecise — or hand-writing XML. And the two platforms want different formats, so the same session gets built twice.
Garmin's MCP has structured builders for running, strength and walk/run, but
nothing for cycling: a bike session there means raw JSON through
upload_workout. MyWhoosh has no usable API at all.
So: one spec in, two files out.
Related MCP server: intervals-icu-mcp
Scope
The server takes a workout spec and emits valid files. It is pure and
deterministic — no network, no auth, no credentials. Writing a rendered file to
disk, when you pass out_path, is the only side effect it has.
Uploading is not part of the server. Uploads need auth and browser state, and a MyWhoosh export spends a limited resource, so they need a human in the loop. They ship as skills instead.
Install
Requires Python 3.10+.
Claude Code — the plugin
Installs the server and the skills, in every session, from any directory:
/plugin marketplace add elias-ramzi/ClaudeCyclingMCP/plugin install claude-cycling-mcp@cycling-toolsRunning Claude Code from a clone works too — .claude/skills is picked up from
the working directory.
Any MCP client — nothing extra to install
The server registers each bundled skill as an MCP prompt, so the procedures travel with it. Register the server and they appear in the client's prompt menu at the version the server shipped with:
{
"mcpServers": {
"cycling": {
"command": "uvx",
"args": ["--from", "git+https://github.com/elias-ramzi/ClaudeCyclingMCP", "claude-cycling-mcp"]
}
}
}Or from a clone: pip install -e . && python -m cycling_mcp.
Claude Desktop — the one-click extension
Download claude-cycling-mcp.mcpb from the
latest release and drag it into Claude
Desktop. It carries the manifest and the skills; the server itself is fetched by uvx on first run,
so you need uv and Python 3.10+ on the machine.
This registers the server and its prompts. For the model to reach for the skills on its own, also do the step below.
Claude Desktop and claude.ai — upload the skills
The prompts above already work once the server is connected. To also get the model-invoked behaviour — so "put this session on my Garmin" reaches for the skill on its own — upload the skills to your Claude account:
cd .claude/skills && for s in */; do zip -r "${s%/}.zip" "$s"; doneThen in Claude, Customize → Skills → +, and upload one .zip per skill.
They are per-account and sync to Desktop, claude.ai and Cowork. Requires code
execution enabled on your plan.
The spec format
Flat JSON, meant to be read and edited by hand. You should be able to look at it and see the session.
{
"name": "Sweet Spot 3x10",
"ftp": 255,
"filename": "sweetspot-3x10",
"description": "70 min sweet spot",
"blocks": [
{"type": "ramp", "duration": "20:00", "from_w": 130, "to_w": 180, "role": "warmup",
"message": "Easy spin, let the legs open up"},
{"type": "steady", "duration": "10:00", "power_w": 232, "cadence": 90,
"message": "Sweet spot 1 of 3", "hr_note": "expect 145-155 bpm"},
{"type": "steady", "duration": "05:00", "power_w": 145, "role": "recovery"},
{"type": "steady", "duration": "10:00", "power_w": 232, "cadence": 90},
{"type": "steady", "duration": "05:00", "power_w": 145, "role": "recovery"},
{"type": "steady", "duration": "10:00", "power_w": 232, "cadence": 90},
{"type": "ramp", "duration": "10:00", "from_w": 140, "to_w": 130, "role": "cooldown"}
]
}describe_spec on that gives:
Sweet Spot 3x10 — FTP 255 W
1:10:00 total · NP 198 W · IF 0.77 · TSS 70 · 772 kJ
# Block Dur Elapsed Target Cadence Notes
- -------- ----- ------- ------------------------ ------- --------------------------------------
1 warmup 20:00 20:00 130 -> 180 W (51 -> 71%) Easy spin, let the legs open up
2 interval 10:00 30:00 232 W (91%) 90 rpm Sweet spot 1 of 3 | expect 145-155 bpm
3 recovery 05:00 35:00 145 W (57%)
4 interval 10:00 45:00 232 W (91%) 90 rpm
5 recovery 05:00 50:00 145 W (57%)
6 interval 10:00 1:00:00 232 W (91%) 90 rpm
7 cooldown 10:00 1:10:00 140 -> 130 W (55 -> 51%)That spec is in examples/sweetspot-3x10.json.
Top level
Field | Required | Meaning |
| yes | Workout title on both platforms. |
| yes | Watts. Required even when every block is in watts, because |
| yes | At least one. |
| no | Filename stem for the |
| no | Copied into the |
| no | Half-width of the watt band put around a single-number target for Garmin, which needs ranges. Default 2. |
Blocks
steady, ramp, free, and repeat.
Power units are explicit — there is no guessing from magnitude. A steady
block carries either power_pct or power_w, never both. Ramps use
from_pct/to_pct or from_w/to_w.
Either form takes a single number or a [low, high] pair:
{"type": "steady", "duration": "10:00", "power_pct": 91}
{"type": "steady", "duration": "10:00", "power_pct": [89, 93]}duration is whole seconds (600) or a clock string ("10:00", "1:05:00").
Repeated sets nest one level:
{"type": "repeat", "count": 3, "blocks": [
{"type": "steady", "duration": "04:00", "power_pct": 105},
{"type": "steady", "duration": "02:00", "power_pct": 55, "role": "recovery"}
]}Optional on any block:
cadence— target rpm, single value or[low, high].message— on-screen text.hr_note— a heart-rate check figure. Carried as a message on both platforms and never as a control target. Both platforms drive on power; an HR range in a session description is something to check against, not chase.role—warmup/interval/recovery/cooldown. Picks the Garmin step type; the.zwoignores it. Inferred when absent: first top-level block is warmup, last is cooldown, the rest are intervals.ramp_steps(ramps only) — Garmin has no ramp primitive, so a ramp becomes one step showing the whole range. Set this above 1 to stair-step it into that many Garmin steps instead. No effect on the.zwo.
On FTP
Never hardcode an FTP, and don't trust a profile value silently. Get it from
Garmin's get_cycling_ftp — but that returns an is_stale flag, and it is
often true. On the account this was built against it reports a value from
January 2025 flagged stale. Confirm the number before rendering; everything
downstream is wrong if it's wrong.
get_cycling_ftp reads Garmin's profile. MyWhoosh keeps its own FTP and
there is no API to read it — but the mywhoosh-upload skill is in the browser
anyway, so it reads the FTP out of the builder and renders against that,
rather than assuming Garmin's number applies.
That matters because the two platforms consume FTP at different times:
What lands in the file | What sets the watts actually ridden | |
Garmin | absolute watts, resolved at render time | nothing further — the file is already in watts |
MyWhoosh | fractions of FTP ( | MyWhoosh's own profile FTP, applied at ride time |
So for Garmin, spec.ftp is just the conversion factor for power_pct blocks,
and a mistake shows up in describe_spec as wrong watts.
For MyWhoosh it is sharper. The .zwo stores only ratios, so the watts ridden
are fraction × the FTP in your MyWhoosh profile. If spec.ftp is 255 and
MyWhoosh's profile says 200, a 232 W block is ridden at 182 W, and nothing warns
you — the file is still "correct", it is just scaled to a different athlete.
Keep spec.ftp equal to MyWhoosh's FTP. The skill does this by reading the
FTP from the builder before rendering, so the fractions are right by
construction.
The FTP field in the MyWhoosh builder is a third, separate thing: a preview setting driving the displayed watts and Training Load. It resets to 200 W on import and must be re-entered, but it does not change the stored fractions.
Tools
Tool | Does |
| Errors and warnings, plus a summary when valid. |
| The block table above — computed watts, durations, IF, TSS. |
|
|
| A payload ready for the Garmin MCP's |
| Compares a sent payload against what |
| The spec's JSON schema and authoring notes. |
validate_spec catches the mistakes that actually bite: empty workouts, zero or
negative durations, power resolving outside a sane fraction of FTP (usually
watts and percentages swapped), ramps whose endpoints are equal, both unit forms
on one block, percentages with no FTP, and empty or nested repeats. Unknown keys
come back as warnings, which catches typos like powr_w that would otherwise be
ignored in silence.
Format notes
.zwo (MyWhoosh)
The uploaded filename becomes the library name. MyWhoosh ignores the
<name>tag for this. Setfilenamein the spec and upload under exactly that name. Accepted extensions:.zwo,.xml,.json.Powers are fractions of FTP (
0.91= 91%), never watts. Durations in seconds.Ramps are always emitted as
<Ramp PowerLow PowerHigh>, never<Warmup>or<Cooldown>. Cooldown ramp direction is read differently by different implementations; an explicitRampcannot be misread.PowerLowis the start value, so a descending ramp has it abovePowerHigh.Repeats are flattened into individual blocks, not
<IntervalsT>. MyWhoosh's editor treats anIntervalsTblock as indivisible, so a single repetition can't be adjusted after import.Messages become
<textevent timeoffset="10" .../>nested in their block —timeoffsetis seconds from the start of that block, and ~10 s lets resistance settle before the text is read against the wrong effort.Accented characters and typographic apostrophes render badly in-game, so message text is folded to ASCII, with a warning when folding changed anything.
Garmin workout JSON
Emitted shape, per step:
{
"type": "ExecutableStepDTO",
"stepOrder": 2,
"stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
"endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
"endConditionValue": 600.0,
"targetType": {"workoutTargetTypeId": 2, "workoutTargetTypeKey": "power.zone"},
"targetValueOne": 227.0,
"targetValueTwo": 237.0
}Power targets are absolute watts; percentages in the spec are resolved against the spec's FTP.
Repeats are native
RepeatGroupDTOgroups, each carrying a completeendConditionincluding the numericconditionTypeId: 7. Omitting the id makes the API silently corrupt the repeat count — no error, wrong workout.stepOrderis global and continues through nesting, matching what Garmin's own UI produces.No heart-rate target is ever emitted.
Garmin schema provenance
This is the part most likely to drift, so here is exactly what it was derived against and how.
Derived 2026-08-12, against the live Garmin Connect API via
Taxuspt/garmin_mcp 0.1.0 and
garminconnect 0.3.10.
Reference workout: id 1662651131. Hand-built in the Garmin Connect web UI
to exercise as many constructs as possible, with known inputs. Losing it means
re-deriving the schema from scratch. There is no rename or edit-in-place in the
API — the operations are upload (creates new), delete, and schedule — so it is
left as is.
Two findings, both established by reading that workout's raw API response rather than the MCP's curated projection:
1. Cycling watt targets are target type id 2, not id 6
The Garmin MCP's own upload_workout docstring says cycling watt ranges use
workoutTargetTypeId: 6 / "power.between". Against this API version that is
wrong, and wrong silently. Id 6 uploads without error, and Garmin normalises
it to the key "pace.zone" on a cycling workout — a pace target, not a power
one. Confirmed by upload/fetch probe.
Id 2 with key "power.zone" and targetValueOne/targetValueTwo round-trips
with the watts intact, and is byte-for-byte the shape the Garmin web UI produces
for a watt target. That is what this renderer emits.
2. %FTP is the same target type plus a unit object
The curated read shows an absolute watt range and a %FTP range identically —
both as power.zone with a low/high pair, with nothing to tell them apart. The
raw response distinguishes them:
Entered in the UI as |
| values |
|
200–220 watts | id 2 | 200, 220 |
|
95–111 % FTP | id 2 | 95, 111 |
|
So the %FTP encoding, left open in the original brief, is
targetValueUnit: {"unitId": 253, "unitKey": "percent", "factor": 1.0} on an
otherwise ordinary id-2 power target. The curated read drops that field,
which is precisely why it cannot confirm units, and why a round-trip test that
compares against the curated shape would pass while the units were wrong.
This renderer emits watts (no unit object), because absolute watts are unambiguous and always correct. Percentage targets would only be worth pursuing if you wanted targets that follow a changing FTP.
3. Derived fields are not evidence
estimated_duration_seconds is computed by Garmin's own rules and disagrees
with the arithmetic — the reference reports 5400 s against 5700 s of steps. It
is never sent and never used for verification. estimated_distance_meters and
avg_training_speed_mps are absent on freshly uploaded workouts, so their
absence proves nothing either.
Verification status
The round-trip test passes. It renders a workout exercising every construct, uploads it, fetches it back, compares against what was sent (not against the curated read), asserts no power target was stored as a percentage, and deletes the workout afterwards.
Visual confirmation in Garmin Connect is the one thing automation can't do — the API can accept and echo a structure that still displays oddly.
Skills
Two bundled skills in .claude/skills/, each triggering on
descriptions of a session — "create", "add", "send", "put it on" — not only on
"upload".
garmin-upload— renders, uploads via the Garmin MCP'supload_workout, then verifies by fetching the workout back and comparing it against what was sent, rather than trusting that the call returned success. Offers to schedule it.mywhoosh-upload— drives the MyWhoosh builder through Claude in Chrome, because there is no API. It reads MyWhoosh's FTP out of the builder before rendering, so the fractions in the.zwoare right by construction. Each step states what it expects to see, so a run that breaks after a MyWhoosh redesign reports which assumption failed instead of silently producing nothing.
Two ways a skill runs
The same SKILL.md reaches a client through one of two mechanisms, and they
differ in who decides to run it.
As a skill — model-invoked. The client reads the skill's description and
reaches for it when your request matches: "put this on my Garmin" pulls in
garmin-upload without you naming it. This is what Claude Code does with
.claude/skills, and what an uploaded skill does in Claude Desktop and
claude.ai.
As an MCP prompt — user-invoked. The server registers every bundled skill as
a prompt of the same name, carrying the same instructions, so clients that don't
read .claude/skills can still run them. You pick it from the client's prompt
menu; the model will not reach for it on its own. Each takes an optional
session argument, so you can describe the workout up front instead of being
asked.
The two coexist: prompts always work because they travel with the server, and installing the skills properly on top adds the model-invoked trigger.
What each skill needs to actually run. A skill triggers on description alone, but it can only finish if its dependencies are present:
Skill | Needs |
| this server + the Garmin Connect MCP |
| this server + browser control (Claude in Chrome) |
So the Garmin path is portable to any client with both MCP servers connected, while the MyWhoosh path only works where a browser is drivable. In a client without browser tools the MyWhoosh skill will trigger and then have no way to drive the page.
The MyWhoosh export spends a finite slot credit, so that skill stops and asks for explicit confirmation before exporting, and uses the pause to settle any open question about the session. It then confirms the workout actually appears in My Workouts and that the slot counter decremented — the difference between "clicked the button" and "the workout exists".
Tests
pip install -e ".[dev]" && pytest126 offline tests: watt↔fraction conversion in both directions, duration totals, NP/IF/TSS, both renderers against golden files, XML that actually parses, the round-trip comparison against a recorded real API exchange, and the skill frontmatter that decides whether a skill is ever reached for.
The golden fixture is the 70-minute sweet-spot session in the spec example above. Imported into MyWhoosh it reported 70:00 / 70 TSS / 0.78 IF; the model here gives 70:00 / TSS 70.0 / IF 0.775, so the metrics are pinned to a real measurement rather than to themselves.
The live Garmin round-trip is deselected by default:
pip install -e ".[live]" && pytest -m liveIt needs the Garmin tokens the Garmin MCP already stores (GARMINTOKENS, or
~/.garminconnect). It creates a workout named ClaudeCyclingMCP_test_* and
deletes it afterwards, including on failure. No credentials live in this repo.
Development
uv venv && uv sync --extra dev
ruff check . && ruff format --check . && pytesthow to propose a change, and the two project-specific rules | |
architecture, and the platform quirks that are load-bearing | |
SemVer policy, the release process, publishing setup | |
what changed, and what was verified against the live API | |
why the server holds no credentials, and where they do live |
License
MIT.
Maintenance
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
- AlicenseBqualityDmaintenanceAn MCP server for interacting with the Wahoo Cloud API to manage workouts, routes, training plans, and power zones. It enables users to list, retrieve, and create fitness data through secure OAuth 2.0 authentication.97GPL 3.0
- AlicenseAqualityAmaintenanceRead/write MCP server for Intervals.icu — 51 tools spanning activities, streams, wellness, calendar, gear, and sport zones, plus structured workout generation with a built-in syntax reference for cycling, running, and swimming.5852MIT
- AlicenseBqualityBmaintenanceLocal MCP server for analyzing Intervals.icu accounts, creating AI-assisted training plans, and applying managed drafts to the calendar after explicit confirmation.14AGPL 3.0
- AlicenseAqualityAmaintenanceMCP server for Intervals.icu that enables AI assistants to manage athletic training data, including activities, calendar events, wellness metrics, and workout libraries.19Apache 2.0
Related MCP Connectors
MCP server for Withings health data — sleep, activity, heart, and body metrics.
An MCP server that let you interact with Cycloid.io Internal Development Portal and Platform
List, fetch, create, edit (replace), delete and schedule structured workouts on Garmin Connect (runn
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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/elias-ramzi/ClaudeCyclingMCP'
If you have feedback or need assistance with the MCP directory API, please join our Discord server