Skip to main content
Glama
tomlongfield

Fitness coach MCP server

by tomlongfield

Fitness coach MCP server

A small, read-only bridge between a self-hosted fitness stack and Claude. Exposes eleven 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_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.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. 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.

  • get_hrv_samples — raw heart rate variability readings (SDNN, ms), one entry per actual measurement rather than one per day like everything else above; HRV is logged several times a day, and a daily average would blend together readings from very different contexts (asleep vs. mid-workday stress) into a number that means less than either alone. Not a first-party sync — see "Optional: HRV" below for how this data actually gets in.

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.

Note the expiry. When it lapses, tool calls will start failing with a clear error message telling you to redo this step. There's nothing automatic here — put a reminder in your calendar for ~80 days out, or just re-pair whenever you see the error.

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.

Optional: HRV

get_hrv_samples returns raw HRV readings if you feed them in — Apple Health has HRV, but nothing syncs it to SparkyFitness on its own, and automated export to a custom endpoint turned out to be a paid feature in every third-party app checked (Health Auto Export, Health Export Pro, others). The free route: an Apple Shortcuts automation you build yourself, posting straight to this server's relay. (Health Auto Export also works with the same relay if you'd rather pay for the polished UI — see the note at the end of this section.)

1. One-time SparkyFitness setup. Posting HRV auto-creates a custom measurement category, but SparkyFitness defaults a new category to frequency: "Daily" — one entry per day, silently overwritten by the next post that day. HRV needs every reading kept, not just the last one each day, so switch it to "All" (unlimited entries — verified live: multiple same-hour readings each stored as their own row, not collapsed) right after the first real post ever reaches it (the category has to exist first):

# find the category's id
curl -s -H "Authorization: Bearer $SPARKYFITNESS_API_KEY" \
  "$SPARKYFITNESS_BASE_URL/measurements/custom-categories" | grep -A1 HRV_SDNN

# then, using that id:
curl -s -X PUT "$SPARKYFITNESS_BASE_URL/measurements/custom-categories/<id>" \
  -H "Authorization: Bearer $SPARKYFITNESS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"frequency":"All"}'

2. Set HEALTH_RELAY_SECRET in .env (same generation command as MCP_PASSWORD) — the route 503s if it's unset. This is a separate, narrowly-scoped credential from SPARKYFITNESS_API_KEY on purpose: it's write-only and HRV-only, so it's the one that ends up stored on your phone, not the API key that can read everything.

3. Add a narrower nginx location than the rest of this server — full example in examples/nginx/mcp-server.conf. This route has no reason to be reachable from Anthropic's range, only from wherever your phone can reach it (e.g. a VPN CIDR, matching /authorize's scope above, not the wider / block).

4. Build the Shortcut (Shortcuts app → Automation → + → Personal Automation → Time of Day, e.g. once daily in the evening):

  • Find Health Samples — Sample Type: Heart Rate Variability, filtered to the period you want to sync (e.g. today).

  • Repeat with Each result:

    • Get Details of Health Sample → Value, and → Start Date

    • Format Date (Start Date) → ISO 8601

    • Dictionary: {"value": <Value>, "timestamp": <formatted date>}

    • Add to Variable (a list, initialized empty before the loop) — build up one entry per reading, not an average

  • Get Contents of URL:

    • URL: https://your-domain/health-relay/hrv

    • Method: POST, Headers: X-Relay-Secret: <your HEALTH_RELAY_SECRET>

    • Request Body: JSON → the list variable from the loop (the relay accepts a single {value, timestamp?} object or an array of them)

Test with ?dryRun=1 appended to the URL first — run the automation manually from the Shortcuts app (not waiting for the schedule), check the response, confirm it looks like {"dryRun":true,"wouldPost":[{"value":...,"type":"HRV_SDNN","date":"...","timestamp":"..."}]} for each reading, then drop the query param.

Every reading is stored as its own row with its real timestamp — nothing here pre-averages or otherwise reduces it (see get_hrv_samples' own tool description for why: a single reading is noisy, and there's no established personal baseline yet to compare it against). Days the Shortcut doesn't run (phone locked at the trigger time, same limitation any automation on iOS has) just have no samples that day — that reads as absent, not zero.

If you'd rather pay for Health Auto Export instead of building the Shortcut: its REST API automation sends {data: {metrics: [{name, units, data: [...]}]}}, which lib/health-relay.js also accepts (auto-detected, no config change) and reshapes into the same per-reading posts. Health Auto Export's docs don't commit to one field-naming convention for a reading's value/timestamp within that shape, so the relay guesses a few common ones (qty, Avg, value) — use ?debug=1 on top of ?dryRun=1 for the first real export to confirm the guess matched your actual payload before trusting it unattended.

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 — lib/exercise-library.js resolves them server-side now, at no ongoing context cost to Claude. See npm run update-exercise-library 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 progress photos (not a good fit for a JSON tool response, and more sensitive data than the value justifies) and no exercise sessions (openGym already covers workouts; SparkyFitness's own exercise entries would just be a second, redundant source).

  • No persistence beyond signed tokens. Registered OAuth clients and in-flight authorization codes live in memory; a restart clears them, and Claude will just need to reconnect once. Access/refresh tokens are self-contained signed strings, so a restart doesn't log you out.

  • 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 restarted since you added the connector. Remove and re-add the connector in Claude's settings.

  • Tool calls fail with an openGym 401 — your OPENGYM_BEARER_TOKEN has expired; redo step 1 and restart the server.

  • 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

MIT — see LICENSE.

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
    137 PyPI
    1
    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
    C
    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