Skip to main content
Glama
Fitness coach MCP server

Fitness coach MCP server

by tomlongfield

Fitness coach MCP server

A small, read-only bridge between a self-hosted fitness stack and Claude. Exposes seventeen tools over the Model Context Protocol, so Claude can pull live training and nutrition data instead of you pasting it in:

  • get_recent_workouts, get_exercise_history, get_current_routines, get_weekly_schedule — from openGym. get_recent_workouts includes each workout's own id (for cross-referencing get_activity_sessions' matchedWorkoutId and get_exercise_history's workoutId) and real startTime/endTime clock times (not just the date), so it can be compared against get_activity_sessions below, plus reconstructed PR detail (type — weight/reps/volume/first — and the previous value each beat; openGym itself only exposes a bare pass/fail flag with no detail) and watchStrengthDurationMin where a same-day watch session exists. get_exercise_history tracks a single exercise's progression (top set, estimated 1RM, volume) across sessions without hauling every exercise in every workout to get it — estimated1RM is a derived Epley-formula index for tracking direction, not a measurement; trust the trend, not the absolute number.

  • get_weekly_schedule, preview_next_session, get_muscle_volume, get_muscle_recovery, get_structural_balance — openGym's own answers, computed with openGym's own code: a copy of the relevant part of its frontend logic (frontend/src/lib/) is kept under lib/vendor/opengym/, pinned to a release tag. So the schedule follows the app's rotation mode (sessions A, B, C… done in order on whatever days you train) and one-off day overrides, not just the fixed weekday plan. The preview shows the weight and reps the app will actually open with once its progression policy has had its say, not the routine's stored target. Muscle volume is the Stats screen's "planned vs completed" effective sets per muscle, muscle recovery is its fatigue/recovery map (per-muscle fatigue with a 36-hour half-life, plus retained strength for detraining), and structural balance is its Poliquin/Thibaudeau/ATG lift-ratio check. After upgrading openGym, run npm run update-opengym-vendor (edit VENDOR_TAG in the script first) to re-sync it.

  • get_workout_photos — photos attached to openGym workouts (progress photos), returned as images, fetched from openGym's /api/media/ only when called. Videos come back as their poster frame.

  • Across the openGym tools, volume and top sets count work sets only: warm-up sets are left out, drop-set drops are included, and unilateral sets logged per side are read one side at a time, the same as openGym's own Stats. Sessions marked "Don't count for progression" are flagged, and get_lift_progression_summary leaves them out.

  • get_nutrition_day, get_nutrition_trend, get_bodyweight_trend, get_sleep_trend, get_vitals_trend, get_activity_sessions — from SparkyFitness (treated as the authoritative source for body measurements here — openGym does log a bodyweight figure per workout too, but it's manually re-typed rather than synced from a scale, so SparkyFitness's Apple Health/smart-scale sync is preferred instead). get_nutrition_trend gives per-day calorie/macro/water totals across a window instead of calling get_nutrition_day once per day — unlogged days return null macros rather than 0 (absent isn't the same as a fast), and today is excluded from the averages (partial: true) since a day in progress otherwise skews every macro low. get_bodyweight_trend and get_vitals_trend both pull some fields from SparkyFitness's "custom measurement categories" — a separate data path for Apple Health metrics with no dedicated column (lean body mass, heart rate, VO2 max, HRV, etc.) — not just its fixed check-in schema. See lib/tools/vitals.js's VITALS_CATEGORIES if you want to track more of what's syncing (SparkyFitness can auto-create dozens of these; check GET /measurements/custom-categories on your own instance to see what's actually available — walking-gait and running-form metrics are commonly synced too but deliberately left out here). get_bodyweight_trend's appleBasalEnergyKcal is Apple Health's accumulated basal-energy figure, NOT basal metabolic rate — see its in-code note for why its daily swing is a documented Apple sync bug, not a usable wear-time signal (an earlier version of this connector tried to derive one from it and that was removed as unsound). get_vitals_trend doesn't flag or filter values for plausibility either, for the same reason: judging what's a "real" reading needs context (schedule, diary, history) this connector doesn't have. HRV (SDNN) is in get_vitals_trend and get_daily_summary as a daily min/max/average — what the SparkyFitness iOS app syncs from Apple Health once its Heart Rate Variability metric is switched on. get_activity_sessions surfaces Apple Watch activity (a run, a bike ride) that openGym has no visibility into at all, with a server-side matchedWorkoutId linking a session to the same-date openGym workout when one exists — since a single gym visit can appear as several adjacent watch-detected segments (e.g. cardio warm-up, then strength, then cardio cool-down) rather than one combined session, it's a same-date link, not a claim that segments were merged.

It never writes to either service. It holds one openGym bearer token and one SparkyFitness API key server-side, and gates access behind a password-protected OAuth login that only you complete once (in a browser, when you add the connector in Claude).

Prerequisites — and what's out of scope

This project assumes you already have a working openGym instance and a working SparkyFitness instance, both reachable from wherever this server runs. Installing and configuring those two apps is out of scope here — they're both actively developed, self-hosted apps with their own install docs, and duplicating that documentation would just go stale. Use their own instructions:

examples/opengym/ and examples/sparkyfitness/ each hold a trimmed docker-compose.yml and .env.example — not the authoritative install method, just enough to show the shape of a working setup and, specifically, to flag the one thing in each that has a knock-on effect on this server: whichever host port each app's frontend ends up published on is what you point this server's OPENGYM_BASE_URL / SPARKYFITNESS_BASE_URL at. Both example files link back to the real upstream files for the full, current, fully-featured version.

examples/nginx/ holds three example vhosts (openGym, SparkyFitness, this MCP server) showing a "dedicated subdomain + optional trusted-network allowlist" pattern — again, illustrative, adapt to your own setup.

Once both are up and you know their reachable URLs, continue below.

Related MCP server: fitness-mcp-server

1. Get an openGym bearer token

From a browser tab where you're already signed in to openGym, open DevTools and run this in the Console (a plain visit to the URL sends a GET and will 404 — this needs to be a POST, fired with your session cookie attached):

fetch('/api/pair/create', { method: 'POST', credentials: 'include' })
  .then(r => r.json())
  .then(console.log)

Returns { "code": "K7WQ2MZP" }. Within 5 minutes, redeem it (curl is fine, this doesn't need a browser):

curl -X POST https://gym.example.com/api/pair/redeem \
  -H 'Content-Type: application/json' \
  -d '{"code":"K7WQ2MZP"}'

Returns { "token": "...", "user": {...} }. That token is valid 90 days (openGym's default SESSION_DAYS) — put it in .env as OPENGYM_BEARER_TOKEN.

Renewal is automatic with openGym v1.4.0 or later: once the token is past half its life, openGym hands a fresh one to this server on its next check-in (at most twice a day, whenever the tools are used), and the server keeps it in data/opengym-token.json. So this step is one-time, unless the server goes unused for more than ~45 days or you "sign out everywhere" in openGym — then tool calls fail with a clear error telling you to redo it. On older openGym versions, re-pair before the 90 days are up.

2. Get a SparkyFitness API key

In the SparkyFitness web UI: Settings → Developer & Integrations → API Key Management → create a new key (any name, e.g. "mcp-server"; no expiry needed unless you want one). Put it in .env as SPARKYFITNESS_API_KEY. It's sent as Authorization: Bearer <key> — SparkyFitness distinguishes API keys from session tokens by format, so no other header is needed.

3. Configure

cp .env.example .env
node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"  # run twice

Use the two generated values for MCP_PASSWORD and TOKEN_SECRET. Fill in OPENGYM_BASE_URL, OPENGYM_BEARER_TOKEN, SPARKYFITNESS_BASE_URL, SPARKYFITNESS_API_KEY, and PUBLIC_URL (the externally reachable HTTPS URL you'll serve /mcp at — see step 5). Also set TIMEZONE to your own IANA zone (e.g. Europe/London) — it defaults to UTC, and every tool that resolves "today" (get_nutrition_day with no date given, the lookback window on the trend tools) will resolve the wrong calendar date near your local midnight if left unset in any timezone ahead of UTC.

The knock-on-effect note, in full: if this server runs on the same host as openGym and/or SparkyFitness, point OPENGYM_BASE_URL / SPARKYFITNESS_BASE_URL at each app's internal backend port (e.g. http://127.0.0.1:8080, http://127.0.0.1:3004/api — whatever they're actually listening on, see examples/) rather than their public HTTPS URLs. Going out through the public hostname means routing back in through your own reverse proxy, which can be unreliable (some providers don't support this "hairpin" routing) and will trip any IP allowlist you've put on that app's own proxy config (see examples/nginx/). Going straight to the backend port skips both problems, needs no TLS since it never leaves the box, and needs no changes to the target app's proxy config either.

Also worth checking PORT (default 8787) isn't already taken by something else on the box before you settle on it:

sudo ss -ltnp | grep :8787

4. Install and run

npm install
npm start

Sanity-check it's alive: curl http://localhost:8787/healthz → {"ok":true}.

Before pointing Claude at it, it's worth testing the MCP endpoint itself with the MCP Inspector (npx @modelcontextprotocol/inspector, run on your own machine — it opens a local browser UI) — it can drive the OAuth flow and list your tools, and it's a much faster feedback loop than debugging through Claude's connector UI. Point it at your PUBLIC_URL with transport type "Streamable HTTP" and hit Connect; it'll walk through discovery, registration, and the /authorize login page (enter your MCP_PASSWORD) automatically. Both Claude Code's and MCP Inspector's loopback OAuth callback conventions (/callback and /oauth/callback respectively) are already allowlisted in lib/config.js, so this should work without any config changes. A different MCP client using a different loopback path would need adding there — see isAllowedRedirect in lib/config.js.

Optional: local extensions

If you track a fitness-relevant data source this template doesn't cover — a journal app, a second tracker, whatever — you can add it as a tool without forking this repo. Any file you drop in lib/tools/local/ that exports a registerSomethingTools(server) function is picked up automatically at startup, the same convention every built-in domain module under lib/tools/ already follows.

That whole directory (except its own README) is gitignored, on purpose: a local extension's source lives elsewhere (a private repo, or just the file on your server), never in a commit to this public template, and git pull here never conflicts with it. See lib/tools/local/README.md for the pattern and a minimal example.

5. Put it behind a reverse proxy

Two common patterns, depending on whether you're sharing a domain with openGym/SparkyFitness or using a separate one — pick whichever matches your setup. Full example files are in examples/nginx/mcp-server.conf.

Pattern A: shared domain, /mcp path prefix

Example nginx location block, assuming the other app is on / and this server gets the /mcp prefix (adjust to taste — just make sure PUBLIC_URL in .env matches whatever path you choose exactly):

location /mcp {
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ~ ^/(\.well-known/oauth-(authorization-server|protected-resource)|register|authorize|token)$ {
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

The OAuth endpoints have to live at the origin (not under /mcp) because that's where the OAuth spec expects /.well-known/* to be — that's already how lib/config.js builds the metadata, so no code changes needed, just the proxy routing above.

Pattern B: dedicated subdomain

If you'd rather give this server its own subdomain (e.g. mcp.example.com, separate from the other apps' domains), the whole vhost belongs to it, so a single catch-all location covers /mcp and every OAuth path — no need to carve anything out:

server {
    listen 443 ssl;
    server_name mcp.example.com;
    # ssl_certificate / ssl_certificate_key — managed by certbot or your ACME client

    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Set PUBLIC_URL=https://mcp.example.com/mcp in .env to match.

Optional: restricting access with an IP allowlist

If the other apps' vhosts already restrict inbound traffic to an allowlist (e.g. a VPN CIDR — see examples/nginx/opengym.conf and sparkyfitness.conf), you may want the same for this server. There's a wrinkle: traffic to this server comes from two different places, so a single allowlist covering just one of them will break the other.

  • /mcp, /register, and /token are called by Claude's backend infrastructure. Anthropic publishes a stable outbound IPv4 range for this — 160.79.104.0/21 as of writing, see their IP address docs for the current value — so this is the range to allowlist for these paths.

  • /authorize — the login page — is loaded by your own browser the first time you connect the connector (and again if you ever disconnect/reconnect). It is not called by Anthropic's servers, so restricting it to Anthropic's range instead of your own network will lock you out of logging in.

Split them accordingly, matching /authorize before the catch-all — full example in examples/nginx/mcp-server.conf:

location /authorize {
    allow 203.0.113.0/24;   # your trusted network/VPN CIDR
    deny all;
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location / {
    allow 160.79.104.0/21;  # Anthropic's outbound range
    allow 203.0.113.0/24;   # your trusted network/VPN CIDR, for manual testing
    allow 127.0.0.1;
    deny all;
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Optional: exposing the connector's icon

This server serves a favicon/icon (/favicon.ico, /icon.svg, /icon-512.png, /apple-touch-icon.png — see public/) unauthenticated, on purpose. Claude currently resolves a custom connector's icon via a favicon lookup against this server's domain, done by infrastructure that isn't Anthropic's own backend and isn't your browser — so if you restricted / above, these specific paths need their own open location carved out ahead of it, the same way /authorize does:

location ~ ^/(favicon\.ico|icon\.svg|icon-512\.png|apple-touch-icon\.png)$ {
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

These are static branding images only — nothing in public/ ever contains account data — so leaving them world-readable isn't a meaningful exposure. If you don't care about the connector icon showing correctly, skip this; everything else works identically either way.

6. Run it under a process supervisor

Run it under whatever process supervisor you already use for the rest of your stack (systemd, pm2, docker) — a minimal systemd unit:

[Unit]
Description=Fitness coach MCP server
After=network.target

[Service]
WorkingDirectory=/path/to/fitness-coach-mcp
ExecStart=/usr/bin/node server.js
Restart=on-failure
EnvironmentFile=/path/to/fitness-coach-mcp/.env

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now fitness-coach-mcp

7. Connect it in Claude

Settings → Connectors → Add custom connector → paste your PUBLIC_URL (the full /mcp URL). Claude will open a browser tab to the login page this server serves — enter the MCP_PASSWORD you generated. After that, Claude holds a signed access/refresh token pair and won't ask again unless you disconnect, or ~30 days pass without use (the refresh token is good for a year, refreshed automatically).

The connector alone gives Claude live numbers, but nothing that lives outside either API — injury history, standing goals, coaching judgment calls. A Claude Project with custom instructions and a knowledge file fills that gap. examples/claude-project/ has two loose templates to start from:

  • instructions.md — a project custom-instructions template: splits what's live (connector tools) from what's background (knowledge file), and says explicitly which tool covers which data.

  • fitness-context.md — a very loose structural example of a knowledge file, with every value replaced by a placeholder — the point is the shape (what belongs in "standing constraints" vs. "current status" vs. what should just be a live connector call instead of a hardcoded number), not the content.

(An earlier version of this project resolved built-in exercise IDs via a lookup table kept in Claude's project knowledge. That's no longer necessary — the server resolves them itself from openGym's own exercise catalogue (vendored under lib/vendor/opengym/), at no ongoing context cost to Claude. After an openGym upgrade adds exercises, re-run npm run update-opengym-vendor if a raw ID ever shows up unresolved.)

Neither template is meant to be used verbatim — replace every placeholder with your own actual context.

What's deliberately left out

  • No write access. There's no log_workout or food-logging tool for either service. If you want that later, it's a new tool plus a new scope to think about for whichever API — not a small addition.

  • No SparkyFitness measurement data other than body/food/water. No SparkyFitness progress photos (openGym's workout photos are exposed, via get_workout_photos, and only when that tool is called) and no exercise sessions (openGym already covers workouts; SparkyFitness's own exercise entries would just be a second, redundant source).

  • Almost no persistence. Access/refresh tokens are self-contained signed strings, so a restart doesn't log you out. Registered OAuth clients and the renewed openGym token are saved to data/ (or DATA_DIR), so a restart never needs the connector re-added either — include that directory in your backups. In-flight authorization codes live in memory only.

  • One password, no per-request MFA. Proportionate for a single-user personal server behind your own domain — not something to reuse for anything with other users.

Troubleshooting

  • "unknown client_id" on the login page — the server lost its saved client registrations (data/oauth-clients.json deleted or unwritable — check the log for [oauth] could not save). Remove and re-add the connector in Claude's settings.

  • Tool calls fail with an openGym 401 — the openGym token expired (the server went unused past its renewal window) or was signed out; redo step 1 and restart the server. The server log shows the token's expiry at start-up and each renewal.

  • Tool calls fail with a SparkyFitness 401 — your SPARKYFITNESS_API_KEY was revoked or disabled; generate a new one (step 2), update .env, and restart the server.

  • Claude says it can't reach the server — check the reverse proxy is forwarding the /.well-known/*, /register, /authorize, and /token routes (step 5), not just /mcp. A common failure mode is only proxying /mcp and leaving OAuth discovery 404ing at the origin.

  • invalid_redirect_uri (400) on /register — the client's loopback callback path isn't in ALLOWED_LOOPBACK_PATHS in lib/config.js. Claude Code and MCP Inspector's conventions are both allowlisted already; a different client may use a different path and need adding there.

  • openGym or SparkyFitness tool calls fail even though the credential is valid — if this server is co-located with the target service and its *_BASE_URL points at that service's public HTTPS URL, check whether the target's own reverse proxy has an IP allowlist blocking this server's outbound request. Point the URL at the internal backend port instead (step 3).

License

AGPL-3.0-or-later (see LICENSE). lib/vendor/opengym/ is copied from openGym, which is AGPL-3.0-or-later, so this repo is licensed the same way.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    B
    maintenance
    Enables Claude to access and query your Garmin Connect data, including sleep, activities, training load, and health metrics, through a set of read-only MCP tools.
    28
    169 PyPI
    5
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Provides MCP servers for Claude to access fitness data from Strava and intervals.icu, enabling natural language queries for activity analysis, advanced training metrics, and wellness tracking.
    1
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    A personal remote MCP server that lets Claude read your Hevy workout data and MacroFactor nutrition data directly in conversation, with read-only tools for workouts, body measurements, macros, and weight trends.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Read-only MCP server aggregating Lyfta workouts, Yazio nutrition, and weight data. Exposes tools for querying workouts, nutrition summaries, and fitness analytics to AI clients.
    MIT