mcp-fitbod
by Muno459
README.md
<p align="center">
<img src="docs/banner.svg" alt="mcp-fitbod" width="100%">
</p>
<p align="center">
<img src="https://img.shields.io/badge/tools-97-f5325b?style=for-the-badge&labelColor=0f1117" alt="97 tools">
<img src="https://img.shields.io/badge/services-10-ff5470?style=for-the-badge&labelColor=0f1117" alt="10 services">
<img src="https://img.shields.io/badge/endpoints-145-7c5cff?style=for-the-badge&labelColor=0f1117" alt="145 endpoints">
<img src="https://img.shields.io/badge/python-3.10+-3776ab?style=for-the-badge&labelColor=0f1117" alt="Python 3.10+">
<img src="https://img.shields.io/badge/license-MIT-2ea043?style=for-the-badge&labelColor=0f1117" alt="MIT">
</p>
<p align="center">
<b>A Model Context Protocol server for Fitbod.</b><br>
Read your training history, invoke Fitbod's own workout generator, configure<br>
the engine that writes your programs, and track body composition over time.
</p>
## Contents
[Overview](#overview) ·
[Install](#install) ·
[Configuration](#configuration) ·
[Tools](#tools) ·
[Authentication](#authentication) ·
[Notable findings](#notable-findings) ·
[Limitations](#known-limitations) ·
[Architecture](#architecture) ·
[Legal](#legal)
## Overview
`mcp-fitbod` puts a Fitbod account behind a single stdio MCP server, so any MCP
client can work with your training data directly. It spans ten backend services
rather than a single endpoint:
| Service | What it holds | Tools |
|:--|:--|--:|
| `nautilus` | routines, gyms, equipment, generator config, profile, workout history | 36 |
| `metros` | body composition time series, strength scoring, streaks, percentiles | 11 |
| `pyserve` | the workout generator, including an LLM backed variant | 4 |
| `coach` | AI coaching sessions with persistent memories | 5 |
| `gympulse` | public gym directory and Places search | 3 |
| `prism` | AI chat backend | 1 |
| `blimp` · `partnerio` · `billing` | in-app messages, Strava, subscription state | 4 |
| `gate-keeper` | login and per-service token minting | auth layer |
The most useful piece is `pyserve`. Most integrations write routines *alongside*
the app; this one calls the generator itself and gets back a complete session
with exercise selection, sets, reps, loads and rest times, then filters the
result against the equipment you actually own and requests replacements for
anything you cannot perform.
**Highlights**
- Every write tool defaults to `dry_run=True` and returns the exact payload it
would send, without touching the account.
- An offline corpus of roughly 1,400 exercises means catalogue lookups, gym
filtering and muscle mapping cost no network calls.
- `build_routine` validates every movement against your gym *before* writing,
and deletes the routine on partial failure so nothing is left half-created.
- Analysis (tonnage, per-muscle volume, estimated 1RM, stalled lifts) lives in
pure functions with no network dependency, unit tested against fixtures.
- Token minting, refresh on `401`, rate limiting, pagination and transient
challenge retries are all handled inside the client.
## Install
Requires Python 3.10 or newer.
```bash
git clone https://github.com/Muno459/mcp-fitbod
cd mcp-fitbod
pip install -e ".[dev]"
```
Authenticate once. The refresh token lasts about a year, and only that token is
persisted.
```bash
FITBOD_EMAIL='you@example.com' FITBOD_PASSWORD='...' python -m fitbod.auth
```
`/users/login` currently sits behind a Cloudflare WAF rule that blocks it
outright (see [Known limitations](#known-limitations)), so that command may
fail even with correct credentials. Nothing else in the package needs it. Carry
a token across from a machine that already has one instead:
```bash
python -m fitbod.auth --export # on the machine that has a token
python -m fitbod.auth --import '<token>' # on the new machine
python -m fitbod.auth --status # confirm account and expiry
```
Build the offline exercise corpus from your own Fitbod install. Extract the APK,
then point the builder at its `res/raw` directory.
```bash
python build_reference.py --apk-res /path/to/fitbod/res/raw
```
Describe your gym so tools can filter to performable exercises.
```bash
cp data/gym-profile.example.json data/gym-profile.json
$EDITOR data/gym-profile.json
```
Verify, then run.
```bash
python -m pytest tests/ -q # offline: parsing and analysis
python smoke_test.py # live: every tool, writes are dry-run
python server.py
```
## Configuration
Register the server with any MCP client over stdio:
```json
{
"mcpServers": {
"fitbod": {
"command": "python",
"args": ["/absolute/path/to/mcp-fitbod/server.py"]
}
}
}
```
| Variable | Purpose |
|:--|:--|
| `FITBOD_EMAIL` | Used by `python -m fitbod.auth` only, never stored |
| `FITBOD_PASSWORD` | Used by `python -m fitbod.auth` only, never stored |
| `FITBOD_REFRESH_TOKEN` | Supply the refresh token directly instead of the credentials file |
Credentials live in `.fitbod-credentials.json`, owner readable and gitignored.
The file holds the refresh token and nothing else. Your password is never
written to disk and never logged. To revoke access, change your password.
`data/gym-profile.json` maps the equipment you own onto Fitbod's 78 equipment
types, including the exact dumbbell and plate increments available to you, so
prescribed loads are ones you can actually assemble. Start from
`data/gym-profile.example.json` and check your names resolve with
`preview_inventory_mapping`.
## Tools
97 tools across twelve modules. Every write tool takes `dry_run`.
<details>
<summary><b>Training history and analysis</b> (10 tools)</summary>
| Tool | Description |
|:--|:--|
| `list_workouts` | Logged workouts, newest first |
| `get_workout` | Full detail for one workout, every set expanded |
| `exercise_history` | Every set logged for one movement, with estimated 1RM |
| `volume_by_muscle` | Working sets and tonnage per muscle group |
| `stalled_lifts` | Movements whose best estimated 1RM has not improved |
| `training_summary` | Totals, per-muscle volume and stalled movements in one call |
| `workout_totals` | Total workout count via the server's own aggregation |
| `body_metrics` | Body metrics from the profile |
| `log_workout` | Log a completed workout into history |
| `update_logged_workout` | Edit a workout already in history |
Estimated 1RM uses Epley. Warmup sets are excluded from working-set counts and
tonnage. An empty window returns an explanatory `note` rather than a bare zero.
</details>
<details>
<summary><b>Workout generation</b> (4 tools, <code>pyserve</code>)</summary>
| Tool | Description |
|:--|:--|
| `generate_workout` | Ask Fitbod's own engine to generate a session |
| `replace_exercises` | Generate, then swap specified exercises out |
| `workout_insights` | Free-text question about a generated workout |
| `generator_reference` | The generator surface and its known constraints |
Three engines are selectable: `foundational_model`, `algo_direct_client` and
`algo_llamabod`. Results are post-filtered against your gym, and anything
impossible is auto-swapped through `exercise_replacement`.
</details>
<details>
<summary><b>Gym and equipment</b> (9 tools)</summary>
| Tool | Description |
|:--|:--|
| `list_gyms` | Gyms on the account with equipment counts |
| `get_gym_equipment` | Equipment currently attached to a gym |
| `preview_inventory_mapping` | How your inventory maps onto Fitbod equipment ids |
| `create_gym_from_inventory` | Create a gym configured from your inventory |
| `add_gym_equipment` · `remove_gym_equipment` | Attach or detach one equipment type |
| `set_available_weights` | Declare which specific weights exist for one type |
| `apply_inventory_weights` | Apply recorded dumbbell and plate increments |
| `delete_gym` | Delete a gym |
</details>
<details>
<summary><b>Generator configuration</b> (6 tools)</summary>
| Tool | Description |
|:--|:--|
| `get_workout_config` · `update_workout_config` | Goal, split, experience, days per week, supersets, warmups, duration |
| `get_blocks` · `create_block` | Training blocks and their focus exercises |
| `set_exercise_priority` · `list_exercise_priorities` | Per-exercise coefficients biasing selection |
</details>
<details>
<summary><b>Routine authoring</b> (9 tools)</summary>
| Tool | Description |
|:--|:--|
| `build_routine` | Create a complete routine in one call, with rollback |
| `list_routines` · `get_routine` | Read saved templates |
| `create_routine` · `update_routine` · `delete_routine` | Template lifecycle |
| `add_set_to_routine` | Add a prescribed exercise |
| `create_superset` | Create an exercise group container |
| `create_custom_exercise` | Define a movement Fitbod does not model |
</details>
<details>
<summary><b>Body composition</b> (11 tools, <code>metros</code>)</summary>
| Tool | Description |
|:--|:--|
| `body_composition` | Current composition with full history per metric |
| `lean_mass_trend` | Lean mass and weight over time, with deltas |
| `muscle_strength` · `muscle_strength_detail` | Per-muscle scoring against the population |
| `population_percentiles` | Where a lift ranks against everyone else |
| `training_streak` · `set_goal_progress` | Streaks, weekly counts, volume targets |
| `list_metric_types` · `metric_details` | The metric catalogue and its numeric ids |
| `record_body_metric` · `delete_body_metric` | Write and remove readings |
A full time series is available for weight, lean mass, fat mass, body fat, BMI,
BMR and seven circumferences, sourced from Apple Health or Health Connect.
</details>
<details>
<summary><b>Catalogue, profile, coach and other services</b></summary>
Catalogue: `find_exercises` `exercise_detail` `search_exercises_live`
`exercise_details_live` `list_equipment` `muscle_groups` `warm_start_lookup`
`onboarding_one_rep_maxes` `seed_one_rep_max` `api_surface` `gym_profile`
Profile: `get_profile` `update_profile` `add_injury` `list_injuries`
`delete_injury` `rate_exercise` `list_exercise_ratings` `registered_devices`
`app_config` `selected_cardio` `add_selected_cardio` `remove_selected_cardio`
`selected_resistance_bands`
Coach and chat: `coach_ask` `coach_sessions` `coach_new_session`
`coach_memories` `coach_session_stats` `chat`
Analytics: `exercise_analytics` `exercise_benchmarks` `workout_achievements`
Other services: `service_health` `subscription_status` `list_blimps`
`dismiss_blimp` `strava_connection` `list_public_gyms` `public_gym_equipment`
`search_places`
Nutrition: `nutrition_targets` `todays_targets` `recalculate_targets`
`cut_progress`
Escape hatches: `raw_get` and `metros_raw_get`, both read-only with path
validation.
</details>
## Authentication
Three steps, with every microservice minting its own short-lived token from one
long-lived refresh token:
```
POST gate-keeper.fitbod.me/users/login {"user":{"email","password"}}
-> 201, refresh JWT in the Authorization RESPONSE header, exp about 1 year
POST <service>.fitbod.me/access_token {"refresh_token":"<raw jwt>"}
-> 201 {"access_token": ...}, aud=<service>.prod.fitbod.me, about 24h
Authorization: Bearer <access_token> -> https://<service>.fitbod.me/...
```
The client caches access tokens for 23 hours and re-mints transparently on a
`401`. Note the per-service inconsistency: `nautilus` requires `refresh_token`
in the body, while `prism`, `blimp` and `metros` also accept it as a header.
The transport matters and the headers do not, which is the opposite of the
usual advice. Do not port this to `requests`, `httpx` or `curl_cffi`, and do
not reach for browser impersonation when something returns a 403. See
[Notable findings](#notable-findings).
## Notable findings
Behaviour that costs real time to work out, documented so it does not have to be
rediscovered.
**Two serialisation regimes coexist.** JSON:API attributes are `snake_case`, but
embedded documents use the source property name verbatim unless an explicit
annotation overrides it. Inside `individual_sets`, `isWarmup` and `restTime`
stay camelCase while `_id`, `_created_at` and `is_amrap` carry overrides.
Confusing the two silently drops every field.
**Some mappings are unguessable.** `workoutConfigId` serialises as
`default_workout_config_id`, `circuitsEnabled` as `supersets_enabled`, and
`algorithmCoefficient` as `algorithm_coeffecient`, misspelled on the wire.
**Logged sets do not use the resource id.** They reference exercises by
`exercise_external_resource_id`, which is a different number. Leg Press is
`id=291` but `external_resource_id=218`.
**POST and PUT disagree about ids.** `POST workout_data` rejects any `id`
(`data.attributes.id should be type integer_id`), while `PUT workout_data/{id}`
requires `data.id` to match the path.
**Relationships versus attributes.** `gym_id` and `equipment_id` are readable
but not writable. Creating a `gym_equipment` row needs JSON:API relationships,
not attributes.
**Enum values are fixed and order matters.**
```
FitnessGoal 0 GENERAL_FITNESS 1 STRENGTH_TRAINING 2 MUSCLE_TONE
3 BODYBUILDING 4 POWERLIFTING 5 OLYMPICWEIGHTLIFTING
MuscleSplit 0 FRESH_MUSCLE_GROUPS 1 UPPER_LOWER 2 FULL_BODY 3 PPL
ExperienceLevel 0 BEGINNER 1 INTERMEDIATE 2 EXPERT
WorkoutVariability 1 MORE_CONSISTENCY 3 SUGGESTED 5 MORE_VARIABILITY
StrengthAggregate push, pull, lower (there is no upper and no core)
ExerciseRating like, dislike, exclude (lowercase; uppercase returns 500)
```
**Generator quirks.** `algo_versions` is required but every field inside may be
null. An empty `user_equipment` or `muscle_usages_from_client` returns a 500.
`exercise_replacement` needs the workout echoed back as `current_workout`, and
every id in `exercise_ids_to_replace` must appear in it. The generator does not
strictly honour `user_equipment`, so always post-filter.
**Browser impersonation makes things worse, not better.** Measured head to head
against one endpoint with the same bearer token, varying only the client:
```
urllib (stdlib) 4/4 pass
urllib + Chrome client-hint headers 4/4 pass
curl_cffi, no impersonation 0/4 challenge
curl_cffi, chrome impersonation 0/4 WAF block
```
Cloudflare's rule keys on the TLS and HTTP/2 fingerprint, and blocks the curl
family. The stdlib fingerprint passes. Adding or removing Chrome client-hint
headers changes nothing at all, and the app's own `fitbod-android/<version>`
agent is blocked the same as any other. This is why the client deliberately
stays on `urllib`.
**Bursts get challenged.** A `403` with an HTML body is transient and clears in
roughly 10 to 30 seconds. The client retries it rather than surfacing it as an
auth error, which is what it resembles at first glance.
**A sustained IP block looks identical but is not.** Cloudflare will also block
an address outright, and that one does not clear on retry. The two are easy to
confuse because both are `403` with an HTML body. Distinguish them by checking a
second host: a burst challenge is per-endpoint, while an IP block returns `403`
for every service and every path, including a plain `GET /` on `gate-keeper`.
```bash
for h in gate-keeper nautilus metros pyserve; do
curl -s -o /dev/null -w "$h %{http_code}\n" "https://$h.fitbod.me/"
done
```
All `403` means the address itself is blocked, which is a different problem
from the endpoint rule above and has a different fix: no client change helps,
so change egress. Measured: an address returning `403` on all four hosts logged
in successfully on the first attempt through a VPN, unchanged client. Login is
annual, so one request through a tunnel is enough - the resulting refresh token
works fine from the normal connection afterwards, and `--export` / `--import`
move it if the tunnel is somewhere inconvenient.
**Durations are seconds.** `duration` on a logged workout is not minutes, which
is easy to miss until a 65 minute session reads as 3908.
## Known limitations
Confirmed against a subscribed account with every payload shape tried:
| Endpoint | Behaviour |
|:--|:--|
| `POST set_breakdown_templates` | `403` on every shape while `GET` works. Literal per-set weight and reps cannot be prescribed. Steer load through `theoretical_max` instead. |
| `POST circuit_templates` | `403`, same pattern. Supersets cannot be created through the API. |
| `rpe` on `POST workout_data` | `400` for every value including null. Readable, never writable. |
| Coach sessions | Cannot be deleted (`405`), so tests never create them. |
| `POST users/login` | Blocked by a Cloudflare WAF rule (`Sorry, you have been blocked`) on every transport, user agent and backoff tried. The address is not banned: the same client mints access tokens and calls every other endpoint normally. Use `--export` and `--import` to move an existing refresh token instead. |
There is no nutrition API at all. The nutrition tools store targets locally and
derive them from live body composition.
## Architecture
```
server.py thin entrypoint, registers tool modules
fitbod/
client.py auth chain, rate limiting, retry, pagination
auth.py python -m fitbod.auth
schemas.py dataclass parsers for both serialisation regimes
analysis.py volume, stalled lifts, 1RM. Pure, network free
reference.py offline corpus, dual id index, gym filtering
tools/ read, catalogue, gym, config, program, profile,
metrics, generate, services, coach, extras, nutrition
tests/ offline tests over recorded fixtures
docs/
API-MAP.md 145 endpoints, 365 request and response models
SCHEMAS.md field level schemas with exact wire names
build_reference.py builds the offline corpus from an extracted APK
smoke_test.py exercises all 97 tools live, writes dry-run by default
```
Requests are rate limited client-side to 6 per second with a burst of 10. That
is a politeness budget rather than a discovered ceiling: 50 concurrent requests
sustained about 32 per second with no `429`s and no rate limit headers on any
response. There is headroom if you need throughput.
The exercise corpus is keyed on a dual index, because logged sets and catalogue
entries use different id spaces. `reference.by_id()` and
`reference.by_external_id()` both exist for that reason.
## Documentation
| File | Contents |
|:--|:--|
| `docs/API-MAP.md` | The complete surface: 145 endpoint declarations across 12 interfaces, with 365 request and response models resolved transitively |
| `docs/SCHEMAS.md` | Field-level schemas with exact wire names, types and required flags |
| `docs/GAPS.md` | What remains unresolved, and why |
### The exercise catalogue
`build_reference.py` builds `data/exercise-reference.json` from the `res/raw`
directory of an extracted Fitbod APK: roughly 1,400 exercises with written
instructions, 78 equipment types, muscle group mappings and equipment weight
tables. That content belongs to Fitbod, so it is not distributed here. Build it
from your own install.
## Legal
This uses an undocumented API and is very likely a breach of Fitbod's terms of
service, even though it only ever touches your own account and does not
circumvent payment. It exists for personal interoperability with your own
training data. Use it on your own account, at your own risk, and do not point it
at anyone else's.
The hexagon mark in the banner is an original drawing inspired by Fitbod's
visual identity, not their trademark, and this project is not affiliated with,
endorsed by, or connected to Fitbod in any way.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues