Fitness coach MCP server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Fitness coach MCP serverHow has my bodyweight trended over the last 30 days?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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_workoutsincludes each workout's ownid(for cross-referencingget_activity_sessions'matchedWorkoutIdandget_exercise_history'sworkoutId) and realstartTime/endTimeclock times (not just the date), so it can be compared againstget_activity_sessionsbelow, 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) andwatchStrengthDurationMinwhere a same-day watch session exists.get_exercise_historytracks a single exercise's progression (top set, estimated 1RM, volume) across sessions without hauling every exercise in every workout to get it —estimated1RMis 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_trendgives per-day calorie/macro/water totals across a window instead of callingget_nutrition_dayonce per day — unlogged days returnnullmacros rather than0(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_trendandget_vitals_trendboth 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. Seelib/tools.js'sVITALS_CATEGORIESif you want to track more of what's syncing (SparkyFitness can auto-create dozens of these; checkGET /measurements/custom-categorieson 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'sappleBasalEnergyKcalis 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_trenddoesn'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_sessionssurfaces Apple Watch activity (a run, a bike ride) that openGym has no visibility into at all, with a server-sidematchedWorkoutIdlinking 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:
openGym: github.com/DuarteSantos8/openGym
SparkyFitness: github.com/CodeWithCJ/SparkyFitness (install docs)
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 twiceUse 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 :87874. Install and run
npm install
npm startSanity-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/tokenare called by Claude's backend infrastructure. Anthropic publishes a stable outbound IPv4 range for this —160.79.104.0/21as 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/hrvMethod: 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.targetsudo systemctl daemon-reload
sudo systemctl enable --now fitness-coach-mcp7. 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).
8. Set up a Claude Project (recommended)
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_workoutor 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_TOKENhas expired; redo step 1 and restart the server.Tool calls fail with a SparkyFitness 401 — your
SPARKYFITNESS_API_KEYwas 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/tokenroutes (step 5), not just/mcp. A common failure mode is only proxying/mcpand leaving OAuth discovery 404ing at the origin.invalid_redirect_uri(400) on/register— the client's loopback callback path isn't inALLOWED_LOOPBACK_PATHSinlib/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_URLpoints 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Talk to your own gym log. Reps is a free workout tracker for iPhone and Android; connect it to Claude, ChatGPT or any MCP client and ask about your workout history, personal records, exercise progression, weekly summaries, routines and training plan. The assistant can also save a new routine, edit one, save a whole plan, add custom exercises and exercise notes, always after you confirm in the chat. It cannot log a workout or delete your history. Requires a free Reps account created in the app; you sign in with a one-time email code.
Connect Claude to your Intervals.icu watch data for fitness, workout review, and plan writing.
Garmin data in Claude: 135 tools — activities, sleep, HRV, training, workouts. Free, open source.
WHOOP recovery, strain, sleep and workouts in Claude via official WHOOP OAuth. Free, open source.
Related MCP Servers
- AlicenseAqualityBmaintenanceEnables 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.28137 PyPI1MIT
- FlicenseNot gradedqualityDmaintenanceProvides 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-
- AlicenseNot gradedqualityCmaintenanceA 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
- AlicenseNot gradedqualityCmaintenanceRead-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