| name | fitness-mcp-setup |
| description | Stand up the `fitness` MCP for the "Fitness" add-on on a new machine and wire it into both Claude clients — an in-repo thin fetch-wrapper over the board's /api/fitness/* routes (like the nutrition/calendar servers). Its writes are add-on-gated (attributed to the agent via `x-actor`) and its reads are open. It is exposed to Claude Code via a supergateway + launchd BRIDGE on $FITNESS_BRIDGE_PORT/:8011 and to Claude Cowork Desktop as a direct stdio command, and the add-on must be ENABLED (Settings.addons.fitness.enabled) for its writes to land + its /fitness + /fitness/health nav to appear. Use when setting up the fitness add-on on a new machine, when Cowork or Code can't see the `fitness` server, when the fitness bridge (:8011) is down, when any write 404s (add-on disabled), or when enabling/disabling the Fitness add-on. |
Fitness MCP setup (thin fetch-wrapper bridge :8011)
Why this exists / architecture
fitness is the second built-in add-on ("Fitness" — Apple Watch health ingestion +
dashboard at /fitness/health, plus the athlete training profile + AI coach at /fitness). Like
nutrition, it has no external repo and no sidecar: the server lives in this Cos checkout
at mcp/fitness-server/server.mjs, and it is the same thin fetch wrapper archetype over the
board's /api/fitness/* HTTP routes on CRM_BASE_URL. It makes no LLM calls — the coaching
intelligence (training plans, weekly reviews, pre-workout briefs) lives on the board's /api/fitness/*
routes (server-side Claude calls) and in the operator skill (/fitness-coach), not here.
Like nutrition, every write is add-on-gated — attributed to the agent via the x-actor header
and blocked only when the add-on is disabled. The Apple-Watch ingest endpoint
(POST /api/fitness/push) is the same: the MCP's push_health_data lands a batch of canonical
entries, gated by the add-on toggle exactly like every other write.
It is a single Node stdio process and registers into Cos the same two ways the core servers do:
- Claude Code reaches it over HTTP via a supergateway + launchd BRIDGE on
:8011
($FITNESS_BRIDGE_PORT, label com.chiefofstaff.mcp-fitness, IN .mcp.json).
- Claude Cowork Desktop spawns it as a direct stdio
command entry in
claude_desktop_config.json — identical to how mcp-bridge-setup wires board/calendar/etc.
Claude Code ──HTTP──> localhost:8011/mcp ──supergateway(launchd)──> node fitness-server (stdio)
│ fetch (x-actor:agent)
└──HTTP──> board /api/fitness/* (:3000)
Cowork Desktop ──spawns stdio directly──────────────────────────────────> node fitness-server (§5)
This is an ADD-ON — its WRITES are GATED, its READS are open. The "Fitness" add-on
must be ENABLED (Settings.addons.fitness.enabled in cases.json) for writes to land. A
disabled add-on 404s every write (the board surfaces it here as a Not found — the fitness add-on may be disabled. tool error) while every GET read stays open, and its /fitness +
/fitness/* nav pages 404 + hide. Enabling is itself a step (§6) — flip it from the board's
/addons catalog (or PATCH /api/addons/fitness).
This add-on's one process (alongside mcp-bridge-setup's four core servers + two sidecars, and the
optional openwhispr / whatsapp / nutrition add-ons):
| process | what runs | env | bridge port | launchd label | in .mcp.json? |
|---|
| fitness (MCP bridge) | node mcp/fitness-server/server.mjs via supergateway | CRM_BASE_URL (the board) | 8011 | com.chiefofstaff.mcp-fitness | yes (http://localhost:8011/mcp) |
Tools (18): ingest — push_health_data (canonical entries), delete_health_data; reads —
list_health_data, get_health_summary, get_daily_summary (health + nutrition for one day),
get_health_trends, ingest_health_to_vault (composes a report for the vault MCP); athlete
profile + computed signals — get_athlete_profile, set_athlete_profile (the goal/level/
availability/sports/equipment singleton; the board validates the enums),
get_form_score (board-computed daily readiness 0-100), get_correlations (board-computed sleep-vs-
performance correlation over N days); coaching artifacts (v13) — save_training_plan,
save_weekly_review, save_pre_workout_brief, save_correlation_report (the four save_* writers,
one per kind), list_coaching_artifacts, get_coaching_artifact, delete_coaching_artifact. Gating:
all 8 writes are add-on-gated (blocked 404 when the add-on is disabled, attributed to the agent via
x-actor); the 10 reads are ungated. So the save_* / set_athlete_profile writes let Cowork persist
a profile + a generated plan / review / brief / report without the board's Anthropic key. See
mcp/fitness-server/README.md for the full contract.
Machine config comes from the loader (run the preamble in §1): it exports $REPO_ROOT,
$BREW_PREFIX, $NODE_BIN, $SUPERGATEWAY_BIN, $LAUNCH_AGENTS_DIR, $COWORK_CONFIG,
$BOARD_URL, and the fitness keys — $FITNESS_BRIDGE_PORT (=8011) /
$FITNESS_BRIDGE_URL (=http://localhost:8011) — use those instead of hardcoding paths, the
Homebrew prefix, your username, or the port.
Prerequisites
- Node + npm (Homebrew) and the server's deps:
(cd mcp/fitness-server && npm i). The server
imports the shared packages/mcp-kit by RELATIVE path, so no workspace install is needed.
supergateway (npm install -g supergateway) — the stdio→HTTP bridge for Claude Code
(:8011), exactly as in mcp-bridge-setup.
- The board reachable at
CRM_BASE_URL (default http://localhost:3000). The server is a pure
wrapper over the board's /api/fitness/* routes — there is no other backend, no external app, no
pairing, no login. (Unlike whatsapp/openwhispr, there is nothing external to install.)
Run the loader preamble as the first line of every shell block below — it exports $REPO_ROOT,
$NODE_BIN, $BREW_PREFIX, $SUPERGATEWAY_BIN, $LAUNCH_AGENTS_DIR, $COWORK_CONFIG,
$BOARD_URL, the bridge port/URL, etc., so nothing below is hardcoded. $U=$(id -u) is derived
inline where launchctl needs it.
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
Steps
1. Confirm the config + install the server's deps
The bridge port is a cos.env key (the loader seeds the 8011 default when absent). Confirm it,
and install the server's deps:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
echo "FITNESS_BRIDGE_PORT=$FITNESS_BRIDGE_PORT ($FITNESS_BRIDGE_URL)"
(cd "$REPO_ROOT/mcp/fitness-server" && npm i >/dev/null 2>&1)
FITNESS_BRIDGE_PORT is already in config/cos.env.example + config/load-config.sh (default
8011, after the search/guardsvc/whatsapp-Go sidecars :8008–:8010). If 8011 is taken on
this machine, change it in config/cos.env and re-render §3/§4.
2. Verify the stdio server runs
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
CRM_BASE_URL="$BOARD_URL" "$NODE_BIN" "$REPO_ROOT/mcp/fitness-server/server.mjs"
- CHECKPOINT — the ready line prints the 18 tool names and echoes the
CRM_BASE_URL it will talk
to. (This only proves the server boots; §3 proves the bridge serves it, §7 proves a tool
round-trips against the board.)
3. Ensure the .mcp.json fitness http entry (Claude Code)
.mcp.json is a generated artifact of the service manifest (rendered from
mcp/fitness-server/fitness.service.json by scripts/gen-mcp-json.mjs, CI-verified with --check)
— never hand-edit it. The repo already ships the fitness http entry pointing at the :8011
bridge:
{ "mcpServers": {
"fitness": { "type": "http", "url": "http://localhost:8011/mcp" }
}}
4. Install the launchd BRIDGE (:8011)
The plist is generated from the co-located descriptor mcp/fitness-server/fitness.service.json by
scripts/gen-launchd.mjs (see mcp/CLAUDE.md) — there is no committed *.plist.template to sed.
The generator resolves the descriptor against the loader's absolute paths + bridge port (launchd can't
expand $VARS, so the rendered plist carries literal values) and, on macOS, renders + bootout →
bootstrap → kickstart in one step:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install fitness
The bridge runs supergateway DIRECTLY around the node child (no launch wrapper — like
board/calendar/nutrition, because this server needs no secret). The generated plist's env (from
the descriptor) is:
-
PATH starting with $BREW_PREFIX/bin (launchd can't see an nvm/asdf shim),
-
CRM_BASE_URL=${BOARD_URL} (the board; from env in the descriptor, pinned so it doesn't depend on the launchd cwd),
-
COS_MCP_IDLE_EXIT_MS=300000 — the idle-exit OPT-IN (idleExit:true), on the bridge only, never in
the direct-stdio Cowork config (see Gotchas).
-
CHECKPOINT — an MCP initialize on :8011 returns serverInfo.name == "fitness":
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
curl -s -X POST "$FITNESS_BRIDGE_URL/mcp" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
| grep -o '"name":"fitness"' && echo "fitness bridge OK"
5. Register Cowork (direct stdio)
.mcp.json (§3) covers Claude Code. Claude Cowork Desktop registers differently:
claude_desktop_config.json ($COWORK_CONFIG) accepts stdio command servers only (an HTTP
url is rejected — the same validated rule as mcp-bridge-setup). Cowork spawns the node server
itself. The Cowork entry is generated from the same descriptor by scripts/gen-cowork-config.mjs
— a backup-first merge that preserves the other servers + preferences and refreshes only the
fitness entry from the resolved loader values, omitting idleExit on the Cowork entry automatically:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
node "$REPO_ROOT/scripts/gen-cowork-config.mjs" fitness
6. ENABLE the add-on (the gate — writes 404 until you do this)
The plumbing is up, but the add-on ships DISABLED: until you enable it, every WRITE 404s and the
/fitness + /fitness/* nav + pages are hidden. Flip it on — either the /addons UI toggle (with
the board app running) or the API (the toggle the UI calls):
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
curl -s -X PATCH "$BOARD_URL/api/addons/fitness" \
-H 'Content-Type: application/json' -d '{"enabled":true}' ; echo
Enabling flips Settings.addons.fitness.enabled to true in cases.json, bumps db.version → SSE,
so the sidebar's Add-ons nav group + the /fitness overview + /fitness/health + the four
/fitness/* coaching pages light up live (no reload), and writes start landing.
Soft dependency on Nutrition. The fitness manifest declares dependsOn: [{id:"nutrition", required:false}] — the get_daily_summary + weekly-review fold db.foodLogs into the coaching
context. This is a SOFT edge: it degrades gracefully when Nutrition is off (the food side just
reads empty), so you do not need the Nutrition add-on enabled for Fitness to work — it just
works better with it (the catalog surfaces this as "works better with Nutrition").
7. End-to-end verify (a tool call round-trips through the board)
Confirm the whole add-on works from the MCP surface, not just the ports — and that the gate is live:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
curl -s -X POST "$FITNESS_BRIDGE_URL/mcp" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"push_health_data","arguments":{"entries":[{"id":"setup-smoke-1","ts":"2026-06-16","type":"steps","data":{"value":8000}}]}}}' \
| grep -o '"accepted"' && echo "write round-trips (add-on enabled)"
curl -s -X POST "$FITNESS_BRIDGE_URL/mcp" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_health_data","arguments":{"type":"steps"}}}' \
| grep -o 'setup-smoke-1' && echo "read works"
curl -s -X POST "$FITNESS_BRIDGE_URL/mcp" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"delete_health_data","arguments":{"ids":["setup-smoke-1"]}}}' \
| grep -o '"deleted"' && echo "delete round-trips (cleanup done)"
If you call (a) BEFORE §6 you'll see Not found — the fitness add-on may be disabled. — that's the
gate working, not a failure; enable the add-on and retry.
- CHECKPOINT —
push_health_data returns an accepted count, list_health_data echoes the
entry, delete_health_data returns deleted, and both clients see fitness after a ⌘Q reopen. The
day-to-day driving (ingesting from the iPhone, reading summaries/trends, setting the athlete
profile, generating a training plan / weekly review / pre-workout brief, pushing a plan to the
calendar, ingesting to the vault) is then owned by /fitness-coach — that skill owns the
operation; this one only proves the plumbing.
8. (Optional) confirm mcp/ensure-bridges.sh brings it up
mcp/ensure-bridges.sh is a thin consumer of the service manifest: it reads
node mcp/service-manifest.mjs --probe-list rather than a hardcoded list, so fitness appears
automatically from its co-located descriptor mcp/fitness-server/fitness.service.json — no
service-list entry and no skip-case to maintain. Because the descriptor carries addon:"fitness",
the probe treats the bridge as OPTIONAL and skips it silently on a board that never installed it
(no WARN … DOWN about :8011), while npm run dev/start brings it up with the rest once the plist
exists. No edit needed; just confirm:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
"$REPO_ROOT/mcp/ensure-bridges.sh" | grep -i fitness
Manage
launchctl kickstart -k gui/$(id -u)/com.chiefofstaff.mcp-fitness
launchctl bootout gui/$(id -u)/com.chiefofstaff.mcp-fitness
tail -f mcp/logs/fitness.err.log
Disable the add-on (keep the plumbing): flip it off — the nav + pages hide and writes 404 again,
but the bridge stays loaded and the data stays on disk + readable:
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
curl -s -X PATCH "$BOARD_URL/api/addons/fitness" -H 'Content-Type: application/json' -d '{"enabled":false}' ; echo
--uninstall (remove the add-on plumbing — the inverse of §3–§6)
Tear the bridge + registrations down (a machine that won't run fitness). This is the inverse of the
install; it does NOT delete data (see the data-purge note below):
source "$(git rev-parse --show-toplevel)/config/load-config.sh"; U=$(id -u)
curl -s -X PATCH "$BOARD_URL/api/addons/fitness" -H 'Content-Type: application/json' -d '{"enabled":false}' >/dev/null 2>&1 || true
launchctl bootout gui/$U/com.chiefofstaff.mcp-fitness 2>/dev/null || true
rm -f "$LAUNCH_AGENTS_DIR/com.chiefofstaff.mcp-fitness.plist"
"$NODE_BIN" - <<'NODE'
const fs = require('node:fs'), E = process.env, p = E.COWORK_CONFIG;
if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p,'utf8'));
if (cfg.mcpServers) delete cfg.mcpServers.fitness;
fs.copyFileSync(p, p + '.bak');
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
console.log('Cowork mcpServers ->', Object.keys(cfg.mcpServers||{}).join(', '));
}
NODE
echo "Uninstalled the fitness bridge + the Cowork entry. ⌘Q + reopen Cowork to drop it from the tool list."
.mcp.json is a generated artifact of the manifest (rendered from
mcp/fitness-server/fitness.service.json by gen-mcp-json.mjs, CI-checked) — do not hand-delete
the fitness entry on a tracked file (it would fail --check). There is no ensure-bridges.sh
line to remove either — the probe is fully manifest-derived. For a per-machine opt-out, leaving
the plist uninstalled is enough — the probe skips fitness silently (its descriptor's addon: field),
so a stray .mcp.json
entry pointing at a dead :8011 is harmless (Claude Code just shows the server as unreachable).
SEPARATE — purge the data (DESTRUCTIVE, explicit, opt-in). Uninstall leaves the
healthEntries array AND the athleteProfile singleton in board/data/cases.json untouched (so a
re-enable finds your history). To actually erase the health data, set healthEntries to []
and remove athleteProfile in the store by hand (or delete entries via delete_health_data — but
there is no API to drop the profile). The store is backed up daily (the arrays ride
cases.json in the encrypted off-site backup), so take a fresh backup first if you want a restore
point. This is intentionally NOT part of --uninstall — disabling/removing the plumbing must never
silently destroy a user's biometric history.
Gotchas (read before editing)
- The add-on must be ENABLED for writes; reads are always open. The gate is
Settings.addons.fitness.enabled in cases.json (assertAddonEnabled inside the board's
mutate() lock). A disabled add-on 404s every WRITE (surfaced as Not found — the fitness add-on may be disabled.) and hides/404s the /fitness + /fitness/* pages, but GET reads stay open and
the /addons catalog link stays reachable. So "push_health_data keeps failing with Not found."
is almost always the add-on is off (§6), not a bridge fault — check /api/addons.
- The COACHING intelligence is NOT in the MCP — the
save_* tools PERSIST, they don't generate.
The 18 tools push/read health data, read/set the athlete profile, read the board-computed form
score + correlations, compose a vault report, and persist/list/delete already-built
coaching artifacts. The four save_* tools (save_training_plan / save_weekly_review /
save_pre_workout_brief / save_correlation_report) take a structured artifact the CALLER built
(the board's /api/fitness/* Claude routes, or Cowork generating it itself) and store it on
db.coachingArtifacts — they make no LLM call. So an agent can author + persist an artifact
via save_* (no board Anthropic key needed — save_* is add-on-gated only), but no
MCP tool "thinks up" a plan; the generation lives in the board routes + the operator skill
(/fitness-coach).
COS_MCP_IDLE_EXIT_MS lives ONLY in the bridge plist, never in the Cowork config. mcp-kit's
idle-exit is OFF by default so Cowork's long-lived stdio child never dies on idle; the §4
supergateway bridge opts in (300000) to reap supergateway's leaked idle stateless child. Setting
it in the Cowork entry manifests as the dreaded "server transport closed unexpectedly". Same rule
as every other bridge.
- No sidecar, no external repo, no secret. Unlike whatsapp (Go + Python, external checkout, QR
pairing) and openwhispr (external app store), fitness is the nutrition archetype: an in-repo
Node
fetch wrapper that needs only the board on CRM_BASE_URL. There is nothing to clone, build,
or pair.
- The board must be reachable on
CRM_BASE_URL. Every tool is a fetch to /api/fitness/*. If
the board app isn't up on :3000 (or wherever CRM_BASE_URL points), tool calls fail with a
connection error — start the board (cd board && npm run dev).
- Port
8011 must be free (lsof -nP -iTCP:8011 -sTCP:LISTEN). It sits after the search/guardsvc
sidecars (:8008/:8009) and the WhatsApp Go bridge (:8010). A foreign process on it breaks §4
— change FITNESS_BRIDGE_PORT in cos.env and re-render §3/§4.
- launchd cannot expand
$VARS. As with every Cos plist, the rendered plist in
~/Library/LaunchAgents carries literal absolute paths + a PATH that starts with
$BREW_PREFIX/bin (launchd never inherits your login shell and can't see an nvm/asdf shim). The
generated plist invokes supergateway by its absolute $BREW_PREFIX/bin/supergateway path; the
inner --stdio "node …/server.mjs" child resolves node via that $BREW_PREFIX/bin-leading
PATH, which is why the PATH is load-bearing.
- The node/simdjson + pm2 gotchas in mcp-bridge-setup apply here too — same Node, same
supergateway, same launchd supervisor. If the bridge dies with a
libsimdjson dyld error after a
brew install, brew reinstall node. Don't reintroduce pm2 — launchd owns the lifecycle (the pm2
mechanism has been retired).