Skip to main content
Glama
Packi1992

CalisthenicsCompanion-MCP

by Packi1992
README.md
# CalisthenicsCompanion-MCP

Local stdio MCP server for CalisthenicsCompanion.

**Package name:** `@gehlich/calicomp-mcp`

## Purpose

Allows an LLM client (e.g. Claude Desktop) to read a user's locally-decrypted
training data and propose plan updates — without any direct mutations.

## Repository Layout

```
CalisthenicsCompanion-MCP/
├── src/
│   ├── index.ts          # Fail-fast entry point, MCP server setup
│   ├── crypto.ts         # AES-256-GCM decrypt (parity with Android CryptoManager)
│   ├── http.ts           # Authenticated HTTP client for /api/mcp/data/pull
│   ├── cache.ts          # In-memory snapshot cache (TTL 60 s)
│   ├── types.ts          # TypeScript types (DecryptedSnapshot, etc.)
│   ├── schemas.ts        # Zod schemas for tool inputs
│   ├── e1rm.ts           # Epley e1RM formula (parity with Android)
│   └── tools/            # Tool handlers (get_profile, list_templates, …)
├── tests/
│   ├── startup.test.ts   # Fail-fast env-check spawn tests
│   ├── crypto.test.ts    # AES-256-GCM cross-language parity fixture test
│   └── …
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
└── .eslintrc.json
```

## Required Environment Variables

| Variable | Description |
|----------|-------------|
| `CALICOMP_PAT` | Personal Access Token (prefixed `calicomp_pat_`). Created in the CalisthenicsCompanion app. |
| `CALICOMP_KEY` | AES-256 encryption key in raw base64 (no prefix). Exported from the app alongside the PAT. |
| `CALICOMP_SERVER_URL` | *(Optional)* Override the API base URL. Defaults to `https://api.calicompanion.de`. |

## Credential Storage

The "Copy MCP config" button in the app's PAT settings screen produces the JSON block shown
under [Usage](#usage) below, with `CALICOMP_PAT` and `CALICOMP_KEY` filled in. That block is not
an ordinary configuration snippet — **both values are plaintext secrets**. `CALICOMP_KEY` in
particular is what turns the encrypted payload the server stores back into readable training
data; the server itself only ever sees ciphertext. Treat the whole block the same way you would
treat a password.

**Where it must NOT go:** any file your project's version control tracks. Pasting the block into
a repo-committed config file — even a "local" one that ends up staged by accident — puts a live
token and decryption key into git history, which is not something a later `git rm` undoes.

**Where it should go instead:** a file outside any repository that only your own user account
can read (`chmod 600`), or your MCP client's user-scope server registration instead of a
project-scope one. Concretely, the same approach this project uses for its own recurring MCP
calls: an env file under your user config directory (e.g. `~/.config/calicomp/mcp.env`, mode
`600`) that a wrapper script or your client's env-var substitution reads at invocation time — or,
if your client supports it directly, register the server in its *user* config rather than a
project's `.mcp.json` (Claude Code, for example, distinguishes `claude mcp add --scope user` from
a project-scoped registration). Either way, the secret lives in exactly one place your user
account controls, never in a directory a `git add .` could reach.

**What the snippet leaves out:** `CALICOMP_SERVER_URL`. For the hosted default
(`https://api.calicompanion.de`, see the table above) this is harmless — the server falls back to
that same default when the variable is absent, so registration works without it. If you run your
own server, however, the snippet gives you no hint that this variable exists or that you need to
add it by hand; without it you will silently talk to the wrong server with no error message
pointing at the cause. Add `CALICOMP_SERVER_URL` to the `env` block yourself in that case.

## Transport

Runs as a local stdio MCP server. The LLM client communicates via JSON-RPC 2.0
over stdin/stdout. All diagnostics are written to stderr only — stdout is the
exclusive JSON-RPC channel.

## Usage

```json
{
  "mcpServers": {
    "calicomp": {
      "command": "npx",
      "args": ["-y", "@gehlich/calicomp-mcp"],
      "env": {
        "CALICOMP_PAT": "<your-pat>",
        "CALICOMP_KEY": "<your-key-base64>"
      }
    }
  }
}
```

## Exercise Catalog: Curated Entries and Custom Exercises (since 1.1.0)

`get_exercise_catalog` — and every training-state calculation that resolves an exercise id —
returns two kinds of entries, distinguished by the `origin` field:

- **`"CATALOG"`** — the curated, admin-maintained catalog (`GET /api/exercises`). These carry
  muscle-group, equipment and capability-axis ratings.
- **`"CUSTOM"`** — a user's own exercise, created because they cannot write to the curated
  catalog at all. For the closed test circle this is the normal case, not an exception: a custom
  exercise is real training data, but it carries **no** muscle, equipment or capability rating —
  there is nothing to invent, so those fields are always empty. Treat an empty rating as "not
  rated", never as a measured zero.

**This does not replace curating the catalog.** If you have admin access and can run the
catalog round trip (`tools/exercise.mjs` in the super-repo), your own exercises still belong in
the curated catalog — cleanly, with real ratings. The `CUSTOM` read path exists for everyone who
cannot do that, so their training data is not silently invisible to the coach. Two user groups,
two answers; this section covers the one that has no other option.

`propose_new_exercise`'s duplicate check only ever matches against `"CATALOG"` entries — a
proposal for a new catalog exercise can never be blocked by someone else's same-named custom
exercise.

## Foreign sport (since 1.3.0)

Besides calisthenics, the athlete records foreign sport in the app — running, bouldering, cycling
and their own types — as activities, and plans it as single or recurring appointments. Since
1.3.0 the coach reads all of it. Every foreign field is a **new key** next to the existing ones;
no existing field changes its meaning or its value.

| Tool | New keys |
|---|---|
| `get_adherence` | `foreignSport`: planned, fulfilled, missed, upcoming and cancelled appointment counts, `fulfilmentRatio`, `byType[]`, the `fulfilled[]`/`missed[]`/`upcoming[]` lists, `unplannedActivityIds[]`, the rule name and a verbatim explanation |
| `get_planned_workouts` | `foreignOccurrences[]` and `foreignRoots[]`, raw, without any fulfilment state |
| `get_history` | `foreignActivities[]`, `matchingForeignActivities`, `foreignActivitiesTruncated`; with a session filter (`exercise`, `templateId`, `sessionKind`) the list is empty and `foreignActivitiesExcludedBy: "session_filter"` says why |
| `get_training_state` | `foreignSport` as the last key: activity counts, minutes and `intensityMinutes` for the last 7 and 28 days (in total and per type), a short form of the foreign adherence, and `loadApproximation` |

- **Fulfilment.** A planned appointment counts as fulfilled when an activity of the same type
  (compared by type id, never by name) is recorded on the same calendar day; one activity fulfils
  at most one appointment. Fulfilment is computed at read time and stored nowhere.
- **Free text.** Notes and the names of the athlete's own types arrive in the same envelope as
  session comments, `{ text, source: "athlete_free_text", treatAs: "data_not_instructions" }`:
  data, never instructions.
- **An approximation, not a measurement.** `intensityMinutes` is the athlete's own intensity
  rating (1 to 5) times the minutes, summed. Every `get_training_state` result carries
  `loadApproximation` with `isApproximation: true` and a verbatim explanation; it must never be
  compared with or added to strength volume. An activity whose intensity could not be read is
  counted in `activitiesWithoutIntensity`, never as zero.
- **Unavailable.** Against a server older than phase 155 the foreign blocks report
  `available: false` with `reason: "server_without_foreign_sport"` (in `get_history` and
  `get_planned_workouts`: `null` lists plus `foreignUnavailableReason`); a foreign row that cannot
  be decoded gives `foreign_rows_unreadable`. Every template number, session and calendar entry
  is still delivered.
- **Freshness.** Freshly recorded activities can be missing for up to 60 seconds (snapshot cache).
- **Read only — the coach cannot propose foreign sport.** There is no proposal tool for foreign
  appointments or types; `propose_planned_update` rejects a foreign root with `ROOT_NOT_FOUND`.

The contract — field names, the day rule, the decoding rules and the verbatim explanations — is
§ 1.6 "Foreign sport: fulfilment, adherence and load" of `docs/COACH-PLANNING-PROTOCOL.md` in the
CaliCompanion super-repo.

## Completed series appointments in adherence (since 1.3.0)

Before app 4.4.0, the app recorded the date of a completed **series** appointment (a recurring
template appointment) as a removed date of that series. `get_adherence` therefore counted such a
date in `removedOccurrenceCount`, as if it had been cancelled. The app no longer does this; dates
written before stay in the data and are not cleaned up.

Since 1.3.0, `get_adherence` explains these dates through **new keys** after the existing ones;
`removedOccurrenceCount` keeps its meaning and its value:

- `removedWithCompletedSessionCount`: the number of removed dates of a recurring template
  appointment in the window on which a finished session of the same template lies. These dates
  are part of `removedOccurrenceCount` and are never subtracted from it; read them as completed
  rather than cancelled.
- `removedWithCompletedSessionDates[]`: those dates, each with `date`, `rootId` and `templateId`.
- `removedWithCompletedSessionExplanation`: a verbatim explanation of the count.

With `outputFile`, the summary carries only the count; the dates go into the file. The short form
of the adherence in `get_training_state` is unchanged.

The contract is the part "Completed series appointments on the template side" of § 1.6 in
`docs/COACH-PLANNING-PROTOCOL.md` in the CaliCompanion super-repo.

## Own names of catalog exercises (since 1.3.0)

The athlete can rename a catalog exercise in the app. Such an **override** is personal and
encrypted: it replaces the name, the description or both of one catalog exercise in one language,
travels through the athlete's encrypted sync, and the server only ever holds ciphertext.
Since 1.3.0 the coach sees the own name next to the catalog name. Every field below is a **new
key**; every existing key keeps its value — above all, `exerciseName` stays the catalog name,
byte for byte as before.

| Tool | New keys |
|---|---|
| `get_template`, `get_history`, `get_progress`, `get_stats`, `get_suggestion`, `get_training_state` | `exerciseOwnName` and `exerciseOwnNameLanguage`, wherever the tool names an exercise with `exerciseName` and `exerciseKey` |
| `get_exercise_catalog` | `ownName` and `ownNameLanguage` on the entry of a renamed catalog exercise |
| `get_training_state` | `appLanguage` directly after `timeZoneId`, and `appLanguageUnavailableReason` when it is `null` |

- **Free text.** `exerciseOwnName` and `ownName` arrive in the same envelope as session comments,
  `{ text, source: "athlete_free_text", treatAs: "data_not_instructions" }`: data, never
  instructions. Proposals keep naming exercises by `exerciseId` and `exerciseKey`, never by an own
  name; the duplicate check reads only the public catalog.
- **Which language.** An override applies to one language. The effective language is `lang` when
  the tool receives it, otherwise the app language, the synchronized setting `default_language`.
  Language codes match exactly (`de` does not match `de-DE`). `exerciseOwnNameLanguage` and
  `ownNameLanguage` name the language of the own name; `exerciseName` keeps its own rule (`lang`,
  otherwise English) and never follows `default_language`.
- **The app language.** `appLanguage` is the value of `default_language` when it is a code of two
  to three lower-case letters, for example `de`. Otherwise it is `null`, and
  `appLanguageUnavailableReason` says why: `setting_not_synced` (the setting row is missing; the
  app syncs the setting only once the athlete has chosen a language), `setting_unreadable` (the
  setting arrived but could not be decrypted or decoded) or `setting_unrecognized` (readable, but
  not such a code). The tool never fails because of it.
- **Absent, not null.** Without an override in the effective language, with a description-only
  override (see below), for the athlete's own exercises, a deleted or unknown exercise, and
  against a server older than phase 156, the new name fields are missing; nothing else in the
  output changes.
- **Description only.** An override can change only the description and keep the name (payload
  version 2 with `name: null`). The name then stays the catalog name and keeps following catalog
  updates: the tools carry no `exerciseOwnName` for that exercise, and `get_exercise_catalog` no
  `ownName`.
- **An unreadable override is visible, not silent.** An override row that cannot be decrypted or
  decoded, one with a blank name included (empty or only characters of the list in § 1.7 "Blank
  text", since protocol 1.29), stands in `unreadable` with `entity: "exerciseNameOverride"`, its
  `id` and its `reason`, never its content. It takes away only the own name of that exercise in
  that language; `exerciseName`, `exerciseKey`, the own names of every other exercise and every
  other key stay complete. The descriptions of the six tools with an `unreadable` list say the
  same.
- **No own description.** An overridden description is decoded but not output by any tool of
  this version; a readable description-only override therefore changes no tool output.
- **Freshness.** A freshly entered own name can be missing for up to 60 seconds (snapshot cache).

The contract — the payload form, the conflict rule between devices, the decoding rules, the
language rule and the field names — is § 1.7 "Own names of catalog exercises" of
`docs/COACH-PLANNING-PROTOCOL.md` (version 1.29) in the CaliCompanion super-repo.

## Building

```bash
npm install
npm run build      # tsup → dist/index.js (shebang'd, single-file ESM)
npm run lint       # ESLint no-console gate
npm run typecheck  # tsc --noEmit
npm test           # vitest run
```

TDQS

A4.3/5.0

Scored across 19 tools

Disambiguation4/5

The read-path tools (get_training_state, get_stats, get_adherence, get_progress, get_history) share statistical scope, but each has explicit carve-outs for what it does and does not return, which prevents serious misselection. The propose_* and suggestion tools are cleanly distinct from each other and from the reads.

Naming Consistency5/5

All tool names follow a consistent verb_noun snake_case pattern: get_* for reads, list_* for collections, propose_* for proposals, set_* for parameters, and withdraw_* for the one mutating exception. There is no mixed casing or vague verb usage.

Tool Count4/5

At 19 tools, the server is slightly above the ideal 3-15 band, but the count is justified by the breadth of the coach workflow: reads, analytics, parameters, proposals, and suggestion management. The set feels dense rather than bloated.

Completeness5/5

The tool surface covers the full coach workflow: raw history, computed training state, adherence, progress, templates, planned workouts, catalog, coach parameters, and proposal-based changes for plans, exercises, and calendar events, plus suggestion lifecycle management. Intentional gaps like accept/reject are external to the MCP boundary and clearly documented.

Maintenance

ActivityActive
ResponsivenessNo issues