FitCoach MCP
by henryhf
README.md
# FitCoach MCP
A remote MCP server that turns Claude or ChatGPT into a fitness coach with memory: persistent goals, conversational workout logging, per-user parameter fitting, and a weekly "plan my sessions" ritual that produces a versioned, explained training plan.
**The division of labor:** the LLM captures conversation and narrates; the deterministic engine in `src/engine/` makes every programming decision (progression, volume, deloads, exercise substitution, run pacing, autoregulation). Same inputs, same plan, always.
> ## Facts live in one place
>
> **[docs/CURRENT-STATE.md](docs/CURRENT-STATE.md) is the source of truth** for
> counts, constants, deployment identity, and what is and isn't built. This
> README deliberately restates as little of it as possible, because an August
> 2026 audit found this file, RUNBOOK, and SUBMISSION-PACK all independently
> claiming 11 tools when there were 22. If a number here disagrees with CURRENT-STATE,
> CURRENT-STATE wins and this file is stale. If CURRENT-STATE disagrees with
> the code, fix CURRENT-STATE first.
## Product mechanics
- **Raw log is append-only.** Sessions, sets, feedback, runs, and recovery metrics are never edited — everything intelligent is derived state in `user_params` (e1RMs, trends, stall detection, recovery score, volume landmarks, run fitness), refit on every planning call.
- **Plans are versioned.** Every `plan_my_week` supersedes the old revision and records a parent pointer + a human-readable rationale — "git, minus git."
- **Trial is a training block, not a clock.** `TRIAL_DAYS = 35` (a 28-day mesocycle + 7 days grace), starting at the first `log_workout`, `plan_my_week`, or `log_run`. Onboarding and `import_history` deliberately do not start it. Read tools are never locked and `delete_my_account` is never gated — your data stays yours.
- **Upgrade happens in the conversation.** A blocked write tool returns a warm upgrade message as tool *content* (not an error) so the model relays it at the moment of intent.
- **`EARLY_ACCESS` is currently ON**, so nothing is gated right now and the trial clock never starts. The gate is fully built and tested; the flag just holds it open. See CURRENT-STATE → Entitlements.
- **Safety screen.** `src/server/safety.ts` runs a deterministic red-flag matcher over all 9 user free-text surfaces, enumerated in one place (`freeTextSources()` in `src/server/tools.ts`). An emergency-tier match strips everything else out of the response, including the upgrade footer — and it runs on the entitlement-blocked path too, so a gated-out user reporting chest pain gets the escalation, not a sales pitch.
## Quickstart (local)
```bash
npm install
npm test # full suite; see CURRENT-STATE for the current count
AUTH_MODE=dev npm run dev # Streamable HTTP MCP server on :3000
```
`AUTH_MODE` is **required and explicit** — the server refuses to start if it is
unset or anything other than `dev` / `supabase`:
```
Fatal startup error: Error: Unknown or missing AUTH_MODE null. Set AUTH_MODE=dev or AUTH_MODE=supabase
```
**Nothing in this repo loads a `.env` file** — there is no `dotenv` dependency
and no `--env-file` flag on the `dev` script. `.env.example` documents the
variables, but copying it to `.env` has no effect: pass variables inline (as
above), export them, or add `--env-file=.env` yourself.
Once running there are two probes, and the difference matters:
`curl localhost:3000/healthz` returns `{"ok":true}` without touching the
database (liveness — it is what Fly polls every 30s, so a DB blip must not fail
it), and `curl localhost:3000/readyz` makes a real query and returns
`{"ok":true,"db":"up","durationMs":N}` or a 503 `db:down`. `/readyz` is the one
external monitoring watches.
In `dev` mode any bearer token `dev-<name>` authenticates as user `<name>`; it
is refused outright when `NODE_ENV=production`.
Connect from Claude (custom connector) or MCP Inspector with URL
`http://localhost:3000/mcp` and header `Authorization: Bearer dev-henry`, then
try: set up a profile, set a goal, log a workout, and `plan my week`.
Other scripts: `npm run build` (tsc + copies migrations into `dist/`),
`npm start` (run the build), `npm run test:watch`, `npm run provision`
(Supabase), `npm run seed-demo`, `npm run metrics`, `npm run deploy` (see
[Deploying](#deploying)).
## Storage
One function decides the backend: `createStorage()` in
[`src/storage/select.ts`](src/storage/select.ts), called once from
`src/server/index.ts`.
| `DATABASE_URL` | Backend | Used for |
|---|---|---|
| set | `PostgresStorage` — Supabase Postgres, ideally the transaction-pooler URL (port 6543) | production |
| unset, dev/test | `PgliteStorage` — embedded Postgres, optionally persisted via `DATA_DIR` | dev and tests |
| unset, production-like | **throws at startup** | — |
"Production-like" means `NODE_ENV=production` **or** `AUTH_MODE=supabase`, and
the refusal is deliberate. `DATA_DIR` is not set in `fly.toml`, so the old
PGlite fallback was *in-memory*: the server booted clean, `/healthz` stayed
green, tools succeeded, and every user got a fresh empty account that emptied
again on the next restart. A dropped secret looked exactly like a healthy
deploy. It now fails loudly instead (`tests/storage-select.test.ts`).
Both are complete implementations of the same `Storage` interface and run the
same migrations (`src/storage/migrations/`, `0001_init` … `0015_rls_v7`, with
RLS policies in the paired `_rls_` files). PGlite is **not** a stub and
Postgres is **not** future work: `PostgresStorage` is a complete, shipped
implementation and is what the live deployment runs. The one
query where the two backends must not drift, the population-tuning aggregate,
is shared verbatim from `src/storage/tuning-shared.ts`.
`PostgresStorage.init()` applies every migration in order at boot. Portable
files are additive and idempotent, so re-running against a live database is
safe; the `_rls_` files reference Supabase's `auth.uid()` and are applied
automatically only when the connected database exposes it — plain Postgres in
local dev and CI skips them.
Note that 71 tests skip unless a real `DATABASE_URL` is present. Run the suite
against Postgres before a release, not just PGlite.
## Deploying
The Fly app is [`fitcoach-hs`](https://fitcoach-hs.fly.dev) — not the package
name. Both `fly.toml` and `scripts/deploy-fly.sh` name it, so a bare deploy is
correct:
```bash
FLY_API_TOKEN=... npm run deploy
```
(Until 0.7.1 both defaulted to `fitcoach-mcp`, so a bare deploy would *create*
that app, deploy there, and health-check its URL — a green run against an app
nobody uses. If a stray `fitcoach-mcp` app is still on the Fly account from
back then, `flyctl apps destroy fitcoach-mcp` it; a decoy that answers
`/healthz` is worse than no app.)
`fly.toml` declares a `[[mounts]]` volume and that is deliberate — it matches
the running machine, which keeps deploys prompt-free. The volume is unused
(prod data is in external Postgres) but it pins the app to a single machine,
which the in-process rate limiter depends on. Details in
[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md).
## Architecture
```
src/
types.ts # binding contracts: domain, Storage, Engine, TOOL_NAMES
storage/
migrations/ # 0001..0015; portable DDL + paired Supabase RLS policies
select.ts # createStorage(): DATABASE_URL ? Postgres : PGlite
postgres.ts # production Storage impl (Supabase Postgres)
pglite.ts # dev/test Storage impl (embedded Postgres)
tuning-shared.ts # the aggregates-only tuning evidence SQL, shared by both
seed-exercises.ts # exercise catalog: substitutes, movement pattern, fatigue cost
engine/ # deterministic; see docs/INTELLIGENCE-DESIGN.md
e1rm.ts # Epley + RPE→RIR adjustment
fitting.ts # fitParams: e1RM smoothing, trends, stalls, freshness, landmarks
planner.ts # planWeek: splits, progression, deloads, hybrid day layout
running.ts # run fitness, program-mode arbitration, run-week construction
adjust.ts # same-day autoregulation (short on time / beat up)
alignment.ts # goal-vs-behaviour drift detection, proactive check-ins
experiments.ts # 2-week n-of-1 plateau tests
recap.ts # weekly recap + PR detection
tuning.ts # bounded population tuning from aggregate evidence
server/
index.ts # express + stateless StreamableHTTP, per-request server factory
auth.ts # dev tokens / Supabase JWT (JWKS) + RFC 9728 metadata
consent.ts # OAuth 2.1 consent UI (Supabase as authorization server)
entitlements.ts # mesocycle trial gate + EARLY_ACCESS
metering.ts # idempotent usage events
safety.ts # deterministic red-flag screen (emergency / injury)
temporal.ts # server-side, timezone-aware natural-language dates
rate-limit.ts # in-process burst + sustained limits
tools.ts, tools-*.ts, tools/*.ts # the tool surface (see CURRENT-STATE)
pages/, site.ts, share.ts, ui/ # landing, /connect, /docs, share links
billing/provider.ts # BillingProvider interface + StubBillingProvider
```
**No payment provider is wired.** `StubBillingProvider` is what runs and
`CHECKOUT_BASE_URL` is unset, so checkout links fall back to the live
`/#pricing` section. Stripe is runbook Phase 4.
## Documentation
| Doc | What |
|---|---|
| **[docs/CURRENT-STATE.md](docs/CURRENT-STATE.md)** | **Source of truth** — counts, identity, what is and isn't built |
| [docs/INTELLIGENCE-DESIGN.md](docs/INTELLIGENCE-DESIGN.md) | The engine: every algorithm, constant, and guardrail |
| [docs/RUNBOOK.md](docs/RUNBOOK.md) | Operating manual — env vars, deploys, `EARLY_ACCESS`, rate limits, auth |
| [docs/INCIDENT-RUNBOOK.md](docs/INCIDENT-RUNBOOK.md) | It's down (or looks down): triage probes and cause playbooks |
| [docs/PRIVACY-CHECKLIST.md](docs/PRIVACY-CHECKLIST.md) | This product stores injury notes and wellbeing text — treat as sensitive |
| [docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md) | Fly specifics |
| [docs/WEARABLES.md](docs/WEARABLES.md) | Recovery-metric ingestion |
| [docs/SUBMISSION-PACK.md](docs/SUBMISSION-PACK.md) | Directory submission material |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing