suunto-mcp
# suunto-mcp
An MCP server for authoring and managing structured workouts (SuuntoPlus™ Guides)
on Suunto, designed so the transport can be swapped without touching the workout
model.
Not affiliated with or endorsed by Suunto Oy.
## Why it's built this way
There are three possible ways to get a structured workout onto a Suunto watch,
and they differ enough that the transport has to be a replaceable part:
| Path | Status | Notes |
|---|---|---|
| Cloud API (`cloudapi.suunto.com/v2/guides`) | Documented, needs a subscription key | The sanctioned path. Contract fully captured in [docs/cloud-api.md](docs/cloud-api.md). |
| Private mobile API (`suuntoplus/guides/*`) | Fully mapped **and live-verified**, reads and writes, unsanctioned | Confirmed by static analysis of the Suunto Android app, then exercised for real: create → duplicate-externalId 409 → update → delete, all against a live account. Full write-up in [docs/private-guides-api.md](docs/private-guides-api.md). Genuinely undocumented; needs no new credential — reuses a `suuntool` session. |
| Local zip | Works today | Emit a validated `guide.zip`; no auth involved. |
`suuntool` deliberately isn't one of these paths: it has no guide-creation
capability at all. What it *does* have is a good, read-only MCP server of its
own — `suuntool mcp` — for completed activity and wellness data, comments,
reactions, and profile info. This server doesn't wrap or re-expose any of
that: run the two side by side as **separate MCP configs**
(`claude mcp add suunto-mcp -- ...` and `claude mcp add suuntool -- suuntool mcp`)
rather than have this one duplicate a surface suuntool already covers better.
The private backend's use of `suuntool`'s session file (below) is credential
reuse, not functionality overlap — it's the reason suuntool is a prerequisite
for the `private` backend either way, so adding its own MCP server alongside
this one costs nothing extra.
Its exit codes double as this server's own error taxonomy: codes 2–7
(`USAGE`/`NETWORK`/`AUTH_EXPIRED`/`SERVER`/`NOT_FOUND`/`FORBIDDEN`) are
numerically identical on both, so a session that has expired in suuntool's own
`session.json` surfaces through the same code as an expired Cloud API token.
## The interesting part
The guide format is a **display** format, not a training format. It has no step
roles, no percentages, no nesting, and a 13-character title budget. So the domain
model is what a coach would write, and `src/compile` lowers it:
- roles (`warmup`/`work`/`rest`/…) → titles, notifications and lap marks
- durations → a per-step `trigger` plus a matching countdown *field*
- **every duration/distance trigger grants lap-skip by default** — a compound
`{type:"or", triggers:[base, {type:"manualLap"}]}` plus `createManualLap:true`,
confirmed live against a real Runna guide after a user reported this
compiler's own workouts couldn't be skipped early. Opt a step out with
`allowSkip: false` to lock it instead.
- **pace ranges → m/s, with the bounds inverted** (4:15–4:25 /km is 3.77–3.92 m/s)
- cadence → **Hertz** (180 spm is 3.0)
- `%HRmax` / `%FTP` → absolutes, resolved from the athlete profile
- nested repeats → flattened, keeping the outer block so the step budget survives
- every string truncated and charset-sanitised for the watch display
Correctness is anchored on Suunto's own published sample guide, which is stored
verbatim in `test/fixtures/` and used two ways: to prove the format model accepts
real Suunto output, and as the compiler's target.
## Layout
```
src/domain/ workout model, guide wire format, validator, limits, activity IDs
src/compile/ the lowering compiler, unit conversions, externalId hashing
src/package/ zip packing (manifest.json + guide.json + icon.png)
src/backends/ the GuideBackend port and its implementations
src/mcp/ MCP server
scripts/ APK acquisition and static analysis for the RE track
docs/ captured API contracts and RE findings
```
## Running it
Tool tiers follow suuntool's: read-only by default, `--allow-write` to create and
update, `--allow-destructive` on top of that to delete. Gating happens at
*registration*, so a tool you have not permitted is absent from the listing
entirely rather than present and always refusing.
```bash
claude mcp add --scope user suunto-mcp -e SUUNTO_MCP_BACKEND=private -- node /path/to/suunto-mcp/dist/mcp/main.js --allow-write
```
| Tier | Tools |
|---|---|
| read | `preview_workout`, `list_workouts`, `describe_backend` |
| `--allow-write` | `create_workout`, `update_workout` |
| `--allow-destructive` | `delete_workout` |
`preview_workout` compiles and validates without uploading, and returns the
warnings — start there.
For completed-activity and recovery data, add `suuntool`'s own MCP server as a
**separate** config rather than expecting this one to cover it:
```bash
claude mcp add --scope user suuntool -- suuntool mcp
```
### Configuration
| Variable | Purpose |
|---|---|
| `SUUNTO_MCP_BACKEND` | `file` (default), `cloud`, or `private` (reuses a `suuntool` session; see the warning above) |
| `SUUNTO_MCP_OUTPUT_DIR` | Where the file backend writes; defaults under `~/.local/share` |
| `SUUNTO_OWNER` | Creator name. Must match the OAuth app name for the Cloud API |
| `SUUNTO_SUBSCRIPTION_KEY` | `Ocp-Apim-Subscription-Key`, required for `cloud` |
| `SUUNTO_ACCESS_TOKEN` | Static bearer token, for trying the API by hand |
| `SUUNTO_CLIENT_ID` / `SUUNTO_CLIENT_SECRET` | Enables refresh of the 24h token |
| `SUUNTO_MAX_HR`, `SUUNTO_THRESHOLD_HR`, `SUUNTO_FTP`, `SUUNTO_REST_HR` | Athlete profile, needed only for `%HRmax` / `%FTP` targets |
Configuration is validated at startup and a bad config is a hard exit — an MCP
server that starts and then fails every call is much harder to diagnose.
## Development
```bash
pnpm install
pnpm test
pnpm typecheck
```
## Reverse-engineering track
```bash
scripts/pull-apk.sh # pull the APK off a connected Android device
scripts/analyze-apk.sh # stage 1: fast dex string scan
scripts/analyze-apk.sh 2 # stage 2: full jadx decompile, only if needed
```
`apk/` and `capture/` are git-ignored and must stay that way — captures contain
session keys and account identifiers.
TDQS
Scored across 3 tools
Each tool has a clearly distinct purpose: preview_workout compiles and validates without uploading, list_workouts retrieves stored workouts, and describe_backend reports backend capabilities. There is no ambiguity or overlap between them.
All tool names follow a consistent verb_noun pattern in snake_case: preview_workout, list_workouts, describe_backend. This is predictable and easy to navigate.
With only 3 tools, the server is at the lower end of the ideal range, but each tool earns its place. The count feels slightly thin for a full workout management workflow, yet is appropriate for a focused validation and overview server.
The server lacks core operations such as create, update, delete, or upload for workouts. It only supports previewing and listing, with describe_backend to check capabilities, leaving significant gaps for any actual modification or synchronization workflow.