Skip to main content
Glama
README.md
# 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

A3.8/5.0

Scored across 3 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count4/5

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.

Completeness2/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues