Metadata-Version: 2.5
Name: wger-mcp
Version: 2.7.0
Summary: MCP server exposing the wger fitness/nutrition REST API as tools, with pluggable auth
Project-URL: Homepage, https://github.com/wger-project/mcp-server
Project-URL: Issues, https://github.com/wger-project/mcp-server/issues
Project-URL: Changelog, https://github.com/wger-project/mcp-server/blob/master/CHANGELOG.md
Project-URL: wger server, https://github.com/wger-project/wger
Author-email: wger team <hello@wger.de>
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: fitness,llm,mcp,nutrition,wger,workout
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: anyio>=4.5
Requires-Dist: httpx>=0.27
Requires-Dist: joserfc>=1.0
Requires-Dist: mcp[cli]<2,>=1.12
Requires-Dist: packaging>=23
Requires-Dist: pydantic-settings>=2.4
Requires-Dist: pydantic>=2.7
Requires-Dist: starlette>=0.37
Requires-Dist: uvicorn[standard]>=0.30
Requires-Dist: wger-api-client<2.8,>=2.7
Description-Content-Type: text/markdown

# wger MCP server

> [!IMPORTANT]
> This is still a WIP, not all things might work correctly yet

An [MCP](https://modelcontextprotocol.io) server that exposes the [wger](https://wger.de) (>= 2.7) fitness/nutrition REST API as tools (routines, workout logging, exercise & ingredient catalog, nutrition plans + meals + recipes, diary, body-weight tracking, gym equipment, body measurements, volume/PR analytics, daily calorie calculator, …) so that AI assistants can read and write your wger data.

It talks to a wger instance over its public REST API — it is a separate service and requires no changes to wger itself.

- **Transport:** **stdio** for a local server your MCP client spawns, or **Streamable HTTP** (FastMCP) for a shared deployment. Pick with `--transport`.
- **Auth:** **multi-user** — every request acts as the calling user's own wger account. wger is itself the OAuth2/OIDC provider, so nothing else is needed ([`MCP_AUTH=wger_oidc`](#wger_oidc--wger-is-the-provider-recommended)); behind an existing SSO, an external OIDC IdP does the same job with a token exchange ([`MCP_AUTH=oidc`](#oidc--an-external-idp)). For single-user self-hosting, [`MCP_AUTH=static_token`](#static_token--single-user-no-idp-required) takes a shared secret plus your wger API key instead.
- **Requires:** wger >= 2.7, Python >= 3.11. The server reads wger's version once at startup and warns if it is older than the API client expects (the major and minor of the installed `wger-api-client`), naming both. It warns rather than refuses: the exercise and ingredient catalogs are stable across releases and keep working, while the tools built on newer endpoints fail visibly when called.

## How auth works

This section describes the **HTTP** deployment, where several people share one
server. Running locally over stdio? None of it applies — skip to
[Quick start — local, over stdio](#quick-start--local-over-stdio), where a wger
API key is the only credential.

Every request acts as the calling user's own wger account, so the server needs a
per-user wger credential. Where that comes from is the one real choice, and wger
2.7 changed the answer.

**On wger >= 2.7 — `MCP_AUTH=wger_oidc`, the short path.** wger is itself an
OAuth2/OIDC provider *and* its REST API accepts the access tokens it issues.
There is nothing to broker: the caller's token goes back out unchanged.

```text
client → MCP    Authorization: Bearer <wger token>   (via MCP-native OAuth)
MCP → wger      Authorization: Bearer <the same token>  on /api/v2/*
```

No identity provider, no client credentials, no exchange — `WGER_BASE_URL` is the
entire configuration. Because the login runs in the user's browser through wger's
own login page, **MFA enrolled in wger simply works**. The tokens are opaque, so
this server does not validate them: wger decides on every call whether a token is
live and which scopes it carries. See
[docs/adr/0005-native-wger-oidc.md](docs/adr/0005-native-wger-oidc.md).

**On wger 2.6, or behind an existing SSO — `MCP_AUTH=oidc`.** wger 2.6 issues its
own JWTs and its API accepts only wger-native credentials, so an IdP token has to
be traded for one:

```text
client → MCP    Authorization: Bearer <OIDC token>   (via MCP-native OAuth, or sent directly)
MCP             validates the token against the IdP's JWKS
MCP → IdP       RFC 8693 token-exchange → access_token aud'd at wger's OIDC client
MCP → wger      POST /allauth/app/v1/auth/provider/token  → a wger JWT
MCP → wger      Authorization: Bearer <wger JWT>  on /api/v2/*   (cached ~5 min per user)
```

Provider-agnostic: JWKS/token endpoints come from the IdP's discovery document (`{issuer}/.well-known/openid-configuration`). No per-user secrets are stored — the wger access token is cached in memory and re-derived on expiry. See [docs/adr/0001-multi-user-auth-via-oidc-token-exchange.md](docs/adr/0001-multi-user-auth-via-oidc-token-exchange.md).

Either way this server stores nothing: no database, no per-user secrets on disk.

## Install

The server is published on PyPI as [`wger-mcp`](https://pypi.org/project/wger-mcp/):

```bash
uvx wger-mcp
```

`uvx` runs it without installing anything permanently; `pip install wger-mcp` (or `uv tool install wger-mcp`) puts the same `wger-mcp` command on your `PATH`. A container image is published as `ghcr.io/wger-project/mcp-server` — see [Deployment](#deployment).

## Quick start — local, over stdio

For your own account on your own machine. The MCP client starts the server as a
child process and talks to it over a pipe, just point it at a wger instance and
give it an API key (wger → *Settings* → *API key*):

```json
{
  "mcpServers": {
    "wger": {
      "command": "uvx",
      "args": ["wger-mcp", "--transport", "stdio"],
      "env": {
        "WGER_BASE_URL": "https://wger.de",
        "WGER_API_KEY": "<your wger API key>"
      }
    }
  }
}
```

That is the whole configuration. `WGER_API_KEY` is the one credential involved;
it acts as your wger account, so treat it like a password.

From a clone instead of PyPI, `--directory` is required — `uv run` looks for the
project in the working directory, which belongs to the client that spawned it,
not to you:

```json
"command": "uv",
"args": [
  "run",
  "--directory", "/absolute/path/to/mcp-server",
  "wger-mcp",
  "--transport", "stdio"
]
```

A few notes specific to this mode:

- `MCP_AUTH` does not apply — there is no inbound request to authenticate. Setting
  it to `oidc` is refused at startup rather than failing later on the first tool call.
- No `.env` is read. The working directory belongs to whichever client spawned the
  process, so a stray file there must not silently configure the server. Pass
  `--env-file PATH` if you do want one. For the same reason the transport itself
  cannot come from such a file — only from `--transport` or the environment — and
  an env file that sets `MCP_TRANSPORT` is refused at startup rather than ignored.
- Logs go to stderr; your client shows them in its MCP log. stdout is the JSON-RPC
  stream and carries nothing else.
- `MCP_TOOLS` works here too — see [Registering only some groups](#registering-only-some-groups)
  if the full set of 78 tools is more than your model handles well.

## Quick start — shared deployment, over HTTP

Configuration comes from environment variables, or from a `.env` file in the directory you start the server in. [`.env.example`](.env.example) documents every setting.

```bash
curl -O https://raw.githubusercontent.com/wger-project/mcp-server/master/.env.example
mv .env.example .env
# Edit .env. Against wger >= 2.7 that is two lines:
#   MCP_AUTH=wger_oidc
#   WGER_BASE_URL=https://wger.de
uvx wger-mcp
```

Server listens on `http://0.0.0.0:8765`, MCP endpoint at `/mcp`.

On wger 2.6, or with an existing SSO in front, set `MCP_AUTH=oidc` instead and fill in `OIDC_ISSUER`, `OIDC_CLIENT_ID`/`SECRET` and `WGER_OIDC_AUDIENCE` — see [`oidc`](#oidc--an-external-idp).

Just trying it against your own account? The [`static_token`](#static_token--single-user-no-idp-required) strategy needs only a wger API key and no IdP:

```bash
# In .env: MCP_AUTH=static_token, MCP_STATIC_TOKEN=$(openssl rand -hex 32),
#          WGER_API_KEY=<your wger API key>, WGER_BASE_URL=<your wger>
uvx wger-mcp
```

Running from a checkout instead (for development, see [CONTRIBUTING.md](CONTRIBUTING.md)):

```bash
git clone https://github.com/wger-project/mcp-server.git
cd mcp-server
uv sync
cp .env.example .env
uv run wger-mcp
```

## Prerequisites for `MCP_AUTH=oidc` (external IdP)

Only for the external-IdP mode. Under [`wger_oidc`](#wger_oidc--wger-is-the-provider-recommended) none of this applies — the prerequisites there live in wger, and are listed in that section.

- **wger** is configured with your IdP as an OIDC social-login provider (`WGER_SOCIAL_PROVIDERS`), so `provider/token` accepts its tokens. `WGER_ALLAUTH_PROVIDER` must match wger's provider id — the slug in wger's `SocialApp` (e.g. `keycloak` or `openid_connect`); it's the `<id>` in the OAuth callback path `/account/oidc/<id>/login/callback/`.
- **IdP** has a *confidential* client for this server (`OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`) with **token-exchange (RFC 8693)** enabled and permitted to exchange to wger's client audience (`WGER_OIDC_AUDIENCE`). On Keycloak that means enabling *Standard Token Exchange* and adding an *Audience* mapper that includes the wger client (otherwise the exchange fails with `Requested audience not available`).
- **MFA is delegated to the IdP.** wger 2.6's headless `provider/token` enforces *wger-side* MFA: a user with a TOTP/WebAuthn authenticator enrolled **in wger** cannot complete the server-side login (no setting skips it). Users must rely on the IdP for MFA and **not** enroll wger-side 2FA. If wger-enforced MFA is a hard requirement, use per-user wger API keys instead of this exchange model.

## Inbound auth strategies

Pick one with `MCP_AUTH=`. The server gates **every** request to `/mcp/*`. `/health`, `/.well-known/*` and the AS-facade endpoints (`/authorize`, `/token` by default) are always public.

| Strategy | Users | Needs an IdP? | Requires | Safe to expose? |
|---|---|---|---|---|
| [`wger_oidc`](#wger_oidc--wger-is-the-provider-recommended) **(recommended)** | multi-user, each acts as themselves | no — wger *is* the provider | wger >= 2.7 | yes |
| [`oidc`](#oidc--an-external-idp) (default) | multi-user, each acts as themselves | yes — external IdP / wger < 2.7 | wger >= 2.6 | yes |
| [`static_token`](#static_token--single-user-no-idp-required) | single-user (shared wger account) | no | wger >= 2.6 | yes, over TLS |
| [`none`](#none--no-inbound-authentication) | single-user, unauthenticated | no | wger >= 2.6 | **no — localhost only** |

`oidc` remains the built-in default so that no existing deployment changes behaviour on upgrade. New deployments against wger >= 2.7 should set `MCP_AUTH=wger_oidc` explicitly.

### `wger_oidc` — wger is the provider (recommended)

Takes the caller's wger access token and puts it on the outbound API call unchanged. The whole configuration:

```ini
MCP_AUTH=wger_oidc
WGER_BASE_URL=https://wger.example.com
MCP_PUBLIC_URL=https://mcp.example.com   # this server's public base URL
#MCP_WGER_SCOPES=openid api:read api:write   # what to ask wger for (default)
#MCP_OIDC_ALLOWED_USERS=alice,bob            # optional allowlist
```

**At wger** the provider has to be switched on (`IDP_OIDC_PRIVATE_KEY`, from `./manage.py generate-oidc-key`) and, unless dynamic client registration is enabled there, an OAuth client row has to exist for each MCP client — see wger's [`docs/administration/oauth2_provider.rst`](https://wger.readthedocs.io/en/latest/administration/oauth2_provider.html). wger's authorize/token endpoints are read from its discovery document at `{WGER_BASE_URL}/.well-known/openid-configuration`.

Notes on what this mode does and does not do:

- **The token is checked with wger.** wger's access tokens are opaque, so the server asks wger's `/api/v2/userprofile/` whether a token is live and caches a positive answer for 60 seconds — keyed by a SHA-256 fingerprint, never by the token. A dead token is answered with `401 invalid_token`, which is what makes an MCP client refresh it; if wger cannot be reached the answer is `503`, so a client does not throw away a token that may be fine.
- **Scopes.** wger gates reads behind `api:read` and every write behind `api:write`; `openid` identifies the user. A grant that is missing one produces a `403` naming the scope, which the tools surface with a *re-authorize this connection* hint rather than as a generic failure. `api:read` is required, since every token is checked by reading the caller's profile. For a read-only deployment, drop `api:write` from `MCP_WGER_SCOPES` and pair it with a read-only [`MCP_TOOLS`](#registering-only-some-groups) selection.
- **MFA works.** The authorization-code flow runs in the user's browser against wger's own login, so TOTP/WebAuthn enrolled in wger applies. This is the one thing the token-exchange mode cannot do.
- **The allowlist is free.** `MCP_OIDC_ALLOWED_USERS` is checked against the username that same lookup returns.
- **If wger is reached over an internal URL**, set `OIDC_AUTHORIZATION_ENDPOINT` to wger's *public* authorize URL: `/authorize` is followed by the user's browser, and discovery against an internal hostname returns internal URLs.

### `oidc` — an external IdP

Validates an IdP-issued Bearer token against the IdP's JWKS, then exchanges it for a wger credential (see *How auth works*). Use this for wger 2.6, or where an SSO provider already fronts everything.

```ini
MCP_AUTH=oidc
OIDC_ISSUER=https://idp.example.com/realms/main   # or https://tenant.auth0.com/
MCP_OIDC_USERNAME_CLAIM=preferred_username  # which claim names the user
#MCP_OIDC_AUDIENCE=wger-mcp                  # if set, inbound aud/azp must match
#MCP_OIDC_ALLOWED_USERS=alice,bob            # optional allowlist

# This server as a confidential OIDC client (token-exchange):
OIDC_CLIENT_ID=wger-mcp
OIDC_CLIENT_SECRET=...
WGER_OIDC_AUDIENCE=wger                      # = wger's OIDC client id at the IdP
WGER_ALLAUTH_PROVIDER=openid_connect         # wger's allauth provider id (slug)
```

JWKS and token endpoints are resolved from the IdP's discovery document (override with `OIDC_JWKS_URI` / `OIDC_TOKEN_ENDPOINT`). Verified on the inbound token: signature (via JWKS), `iss`, `exp`, and — if `MCP_OIDC_AUDIENCE` is set — `aud` (or `azp`, which some IdPs use). JWKS is cached for `MCP_JWKS_TTL_SECONDS` (default 3600 s) and re-fetched on signature failure to handle key rotation.

Interactive MCP clients discover the IdP via OAuth Protected Resource Metadata at `/.well-known/oauth-protected-resource` (a `401` also advertises it in `WWW-Authenticate`). Set `MCP_PUBLIC_URL` to the externally reachable base URL so the advertised resource identifier is correct.

### Authorization-Server facade

Applies to both OAuth modes. Some MCP clients — notably **claude.ai**'s custom connector — do **not** follow the `authorization_servers` pointer to a different host. They treat the MCP server's own origin as the OAuth authorization server: they fetch `{origin}/.well-known/oauth-authorization-server` and run `/authorize` + `/token` against that origin. They also need the OAuth endpoints reachable from where the *client* runs — for a cloud client like claude.ai, the public internet — while the IdP itself can stay private.

To support this, the server exposes a thin **AS facade** in both OAuth modes (`wger_oidc` and `oidc`):

| Path | Behaviour |
|------|-----------|
| `/.well-known/oauth-protected-resource` | `authorization_servers` = **this origin** (self) |
| `/.well-known/oauth-authorization-server` | RFC 8414 metadata; `authorization_endpoint`/`token_endpoint` on **this origin** |
| `/authorize` | `302` to the provider's authorization endpoint (front-channel browser login) |
| `/token` | reverse-proxies to the provider's token endpoint (back-channel) |
| `/register` | reverse-proxies dynamic client registration — only when the provider offers it |

The facade paths default to the conventional `/authorize`, `/token` and `/register` — clients like claude.ai assume those and ignore the `authorization_endpoint` in the AS metadata. Override with `OAUTH_AUTHORIZE_PATH` / `OAUTH_TOKEN_PATH` / `OAUTH_REGISTER_PATH` if a client expects something else (no rebuild needed).

Set `MCP_AS_FACADE=false` to switch the facade off entirely: the metadata then names the provider directly and this origin serves no OAuth endpoints. Correct only if every client you care about follows the `authorization_servers` pointer — claude.ai does not. The pointer is the provider's issuer as its discovery document states it, so under `wger_oidc` `WGER_BASE_URL` has to be wger's public URL for this to work.

#### Scopes and registration under `wger_oidc`


Two things the facade does only in this mode, both for the same reason — a generic MCP client has no way of knowing that wger's API is gated behind `api:read`/`api:write`:

- **`/register` is advertised and proxied** when wger has dynamic client registration switched on (it publishes a `registration_endpoint` in its discovery document exactly then, so there is nothing to configure here). With DCR the connector flow is one URL; without it, every client needs an OAuth client row created in wger by hand.
- **The API scopes are added** to the proxied registration and to the `/authorize` query, on top of whatever the client asked for. A client that registers with `openid` alone is otherwise refused at `/authorize` with `invalid_scope` — allauth requires the requested scopes to be a subset of the client's — and nothing in that failure tells anyone why. What the facade adds is exactly what the consent screen then shows the user.

#### Reachability and endpoints

The provider never has to be publicly reachable: the user's browser reaches it for the login redirect, and the back-channel requests are proxied through this server. Tokens are still minted by the provider, so nothing about how they are checked changes. Its `authorize`/`token` endpoints come from discovery — of `WGER_BASE_URL` under `wger_oidc`, of `OIDC_ISSUER` under `oidc` — and are overridable with `OIDC_AUTHORIZATION_ENDPOINT` / `OIDC_TOKEN_ENDPOINT` in either mode. See [docs/adr/0003-oauth-authorization-server-facade.md](docs/adr/0003-oauth-authorization-server-facade.md).

`MCP_PUBLIC_URL` **must** be set to the externally reachable base URL so the advertised endpoints point at the public origin (otherwise they're derived from the request's `X-Forwarded-*` / `Host`).

#### Adding the connector in claude.ai

Under **`wger_oidc`**:

1. In wger, create an OAuth client with redirect URI `https://claude.ai/api/mcp/auth_callback` (`CONFIDENTIAL`, scopes `openid api:read api:write`) — see wger's `oauth2_provider` docs. Skip this if wger has dynamic client registration enabled; the connector then registers itself through `/register`.
2. In claude.ai → *Add custom connector*: URL `https://<public-host>/mcp`. With a hand-made client, put its id and secret under *Advanced settings*.
3. Approve the consent screen wger shows. It names the scopes, which is the only moment the user sees that they are granting write access to their training, nutrition and body data.

Under **`oidc`**:

1. At the IdP, the confidential client (`OIDC_CLIENT_ID`) needs redirect URI `https://claude.ai/api/mcp/auth_callback` and web origin `https://claude.ai`, plus *Standard flow* and the token-exchange / audience-mapper setup from *Prerequisites* above.
2. In claude.ai → *Add custom connector*: URL `https://<public-host>/mcp`; under *Advanced settings* set Client ID / secret to the IdP client's.

Either way, verify discovery before connecting:

```bash
curl -s https://<public-host>/.well-known/oauth-protected-resource | jq
curl -s https://<public-host>/.well-known/oauth-authorization-server | jq
```

> The interactive `/authorize` step `302`s the browser to the provider, so the **browser** must reach it. With a split-horizon / LAN-only provider that means running the browser on that network; the back-channel `/token` is always proxied through this server.

### `static_token` — single-user, no IdP required

For self-hosting where standing up an IdP is overkill. Callers present a shared secret as a bearer token; the server validates it (constant-time) and then calls wger with your personal DRF API key (Settings → API → "API key").

```ini
MCP_AUTH=static_token
MCP_STATIC_TOKEN=<openssl rand -hex 32>
WGER_API_KEY=<your personal wger API key>
```

The client sends `Authorization: Bearer <MCP_STATIC_TOKEN>`.

Unlike `none`, inbound requests **are** authenticated, so this is safe to expose over TLS. Caveats:

- **Single-user.** Every authenticated caller acts as the one wger account behind `WGER_API_KEY`. Use `oidc` if more than one person needs access.
- **The secret is a password.** It grants full access to that account; rotate it by changing the env var and restarting.
- **Minimum 32 characters**, enforced at startup — a guessable secret is the entire attack surface.
- **No MCP-native OAuth.** The OAuth discovery endpoints are deliberately not served under this strategy (a client following them would run a flow whose token this server never accepts), so configure the token out-of-band in your client.

### `none` — no inbound authentication

**Anyone who can reach `/mcp` acts as the account behind `WGER_API_KEY`** — no credential required. The server logs a warning at startup.

```ini
MCP_AUTH=none
WGER_API_KEY=<your personal wger API key>
```

Safe only when bound to localhost for local development. Never expose it to a network, even behind TLS — use `static_token` instead if you need remote access.

## Tools

Tools are grouped by domain. Each lives in its own module under [`src/wger_mcp/tools/`](src/wger_mcp/tools/).

### Registering only some groups

All 85 tools are registered by default, which is about **18.6k tokens** of schema in every request before the conversation starts. `MCP_TOOLS` narrows that to a comma-separated list of groups:

```bash
MCP_TOOLS=nutrition,off,exercises,profile
```

Valid names are the module names — `profile`, `routines_read`, `routines_write`, `workout_logs`, `workout_sessions`, `body_weight`, `measurements`, `equipment`, `nutrition`, `exercises`, `analytics`, `off` — plus `routines`, which means both routine halves. An unknown name stops the server at startup rather than silently dropping tools, and repeated names are harmless.

This matters most for agents driven by small local models, whose tool-selection accuracy falls off as the surface grows, and where every schema is spent from a modest context window. It is also useful for a single-purpose agent that has no business writing routines.

#### Profiles that cover most agents

Measured, not estimated: the token figures are the serialised tool list divided by four. They include the output schemas the MCP SDK now emits for every tool, which is why they are around a quarter higher than in earlier releases — the tool descriptions themselves did not grow by that much.

| Agent | `MCP_TOOLS` | Tools | ~Tokens |
|------|-------------|------:|--------:|
| Everything (default) | *(unset)* | 88 | 23.5k |
| Coach — writes plans and reads them back | `routines,workout_logs,workout_sessions,exercises,analytics` | 49 | 13.0k |
| Trainee — follows an existing plan and logs it | `routines_read,workout_logs,workout_sessions,exercises` | 28 | 7.3k |
| Food logging | `nutrition,off,exercises,profile` | 29 | 7.4k |
| Read-only review — progress, weight, measurements | `analytics,body_weight,measurements,profile` | 23 | 6.0k |

The training-plan tree is the largest group and splits in two: `routines_read` (9 tools, ~1.5k) reads the plan and answers what it prescribes today, `routines_write` (16 tools, ~4.7k) creates and changes it. An agent that follows a plan needs only the first, which is a fifth of the whole surface saved. `routines` remains valid and still means both.

### Profile

| Tool | Description |
|------|-------------|
| `whoami` | Show the wger user profile of the authenticated caller |
| `update_user_profile(calories?, height_cm?, birthdate?, gender?, sleep_hours?, work_hours?, work_intensity?, sport_hours?, sport_intensity?, freetime_hours?, freetime_intensity?)` | Patch the wger profile (e.g. write your calorie target) |

### Routines (training plan tree)

| Tool | Description |
|------|-------------|
| `list_routines` / `get_routine(routine_id)` | List / read training routines |
| `create_routine(name, description?, start?, end?, fit_in_week?, is_template?, is_public?)` | Create a routine. `is_template` marks it a reusable blueprint; `is_public` additionally offers it to every user of the instance |
| `update_routine(routine_id, ...)` / `delete_routine(routine_id)` | Patch / delete a routine (cascade) |
| `list_routine_days(routine_id)` / `get_routine_day(day_id)` | Read day structure |
| `add_routine_day(routine_id, name, order, description?, is_rest?, day_type?, need_logs_to_advance?)` | Add a training day. `need_logs_to_advance` holds the plan there until sets are logged, instead of advancing by the calendar |
| `update_routine_day(day_id, ...)` / `delete_routine_day(day_id)` | Patch / delete a day (cascade) |
| `list_slots(day_id)` / `add_slot_to_day(day_id, order, comment?)` | List / add exercise slots |
| `update_slot(slot_id, order?, comment?, day_id?)` / `delete_slot(slot_id)` | Patch / delete a slot (cascade). `day_id` moves the slot, entries and configs included, to another day |
| `list_slot_entries(slot_id)` / `get_slot_entry(entry_id)` | Read exercise entries in a slot |
| `attach_exercise_to_slot(slot_id, exercise_id, order?, repetition_unit?, weight_unit?, comment?, entry_type?, repetition_rounding?, weight_rounding?)` | Bind an exercise to a slot. `entry_type` is `normal` (default), `warmup`, `dropset`, `myo`, `partial`, `forced`, `tut`, `iso` or `jump`; the rounding fields snap what a progression computes to a loadable step (e.g. `2.5`). `repetition_unit` and `weight_unit` take a name — pass the name, never a number: `repetitions`=1, `until_failure`=2, `seconds`=3, `minutes`=4, `miles`=5, `kilometers`=6, `max_reps`=7, `meters`=8, and `kg`=1 / `lb`=2. wger's numeric ids are still accepted, and are the only way to reach a unit this server does not name (plates, body weight, km/h) or one your instance defines itself. Omit `weight_unit` and the trainee's own unit from their wger profile is used |
| `update_slot_entry(slot_entry_id, ..., slot_id?, entry_type?, repetition_rounding?, weight_rounding?)` / `delete_slot_entry(slot_entry_id)` | Patch / delete a slot entry. `repetition_unit` / `weight_unit` take a name as well as an id, as on `attach_exercise_to_slot`. `slot_id` moves the entry to another slot |
| `list_slot_entry_configs(slot_entry_id, kinds?)` | Read per-iteration configs (sets/reps/weight/rir/rest/max_*) |
| `set_slot_entry_config(slot_entry_id, kind, value, iteration?, operation?, step?, repeat?, weight_unit?, requirements?)` | Add a per-iteration config record. `weight_unit` applies to `kind='weight'`/`'max_weight'` and is recorded on the slot entry. `requirements` gates the step on what was logged — any of `repetitions`, `weight`, `rir`, `rest` |
| `update_slot_entry_config(kind, config_id, value?, iteration?, ..., requirements?)` / `delete_slot_entry_config(kind, config_id)` | Patch / delete a config record (use to bump weight on progression). `requirements=[]` clears an existing gate |
| `add_exercise_with_sets(day_id, exercise_id, sets, reps, weight?, max_reps?, slot_order?, weight_unit?, rir?, entry_type?)` | Convenience: slot + entry + sets/reps configs in one call. Omit `weight` to prescribe sets without a load. Pass `max_reps` to store a range: `reps` is the bottom and `max_reps` the top, so 8 and 12 record "3 x 8-12"; it has to be above `reps`, since wger reports a range only where the top exceeds the bottom. `weight_unit` is `kg` or `lb`; omit it and the trainee's profile unit is used, as in `log_set` |
| `get_workout_for_date(routine_id, workout_date?)` | What the routine prescribes on a date (default today): one entry per planned SET, with exercise name, `slot_entry_id`, reps, weight and RiR, plus the day's own `day_description` notes. Feed its ids into `log_set` |

### Workout logs

| Tool | Description |
|------|-------------|
| `log_set(exercise_id, reps, weight, workout_log_date?, rir?, weight_unit?, routine_id?, slot_entry_id?, iteration?, reps_unit?, rest?, reps_target?, weight_target?, rir_target?, rest_target?, session_id?, next_log_id?)` | Add a workout log entry. `weight_unit` is `kg` or `lb`; omit it and the trainee's own unit from their wger profile is used — a profile that cannot be read refuses the write rather than guessing, so pass the unit to write anyway. The weight is stored in the unit given, with no conversion. Pass `routine_id` / `slot_entry_id` / `iteration` (all from `get_workout_for_date`) to attach the set to the plan it came from — without them the log is freestanding and no routine view or routine statistic can see it. `reps_unit` says what `reps` counts — pass the name, never a number, and do not read an id off the order of this list: `repetitions` (wger's default), `until_failure`, `seconds`, `minutes`, `miles`, `kilometers`, `max_reps`, `meters`. Without it a plank is stored as 60 repetitions. The `*_target` fields put what was prescribed next to what was done, in the same row |
| `list_workout_logs(date_from?, date_to?, exercise_id?, limit?)` / `get_workout_log(log_id)` | Read entries |
| `update_workout_log(log_id, reps?, weight?, rir?, when?, weight_unit?, exercise_id?, reps_unit?, rest?, *_target?, routine_id?, slot_entry_id?, iteration?, session_id?, next_log_id?)` / `delete_workout_log(log_id)` | Edit / remove an entry. `exercise_id` fixes a set logged against the wrong exercise; the plan-linkage arguments attach a set that was logged freestanding |

### Workout sessions

The training unit a day's sets belong to. wger opens one implicitly for a log that names none, so these tools are what make its own fields reachable — when it ran, how it felt, and what the trainee wants to remember about it.

| Tool | Description |
|------|-------------|
| `log_workout_session(routine_id?, day_id?, started_at?, ended_at?, notes?, impression?)` | Record a session. `impression` is `bad`, `neutral` or `good` — the trainee's own verdict, which no aggregate over the logs can reconstruct. `started_at`/`ended_at` are full timestamps, so a session may run past midnight; a bare date lands at 12:00. Leaving `ended_at` out keeps the session open, to be closed by a patch when the workout ends |
| `list_workout_sessions(date_from?, date_to?, routine_id?, impression?, limit?)` / `get_workout_session(session_id)` | Read sessions, newest first, optionally within a date range (both inclusive). The range is cut on the day a session *started*, which is the day it counts for |
| `update_workout_session(session_id, ...)` / `delete_workout_session(session_id)` | Patch / delete a session. Deleting takes its logged sets with it |

### Body weight

Since wger 2.7 a body weight is a measurement like any other, in an official `body_weight` category every account has from registration. These tools go through `/api/v2/measurement/` rather than the older `/weightentry/` shim, which is what lets the **unit travel with the reading**: the shim reads and writes in whatever the profile says at that moment, so switching a profile from kg to lb reinterprets everything written before it.

| Tool | Description |
|------|-------------|
| `log_body_weight(weight, when?, unit?, notes?)` | Body-weight entry. `unit` is `kg` or `lb` and is recorded with the reading; left out it is the profile's weight unit, so passing it is both more precise and one request cheaper |
| `get_body_weight_history(date_from?, date_to?, limit?)` | Recent entries, each with the reading as recorded (`weight` + `unit`) and the same number in kilograms (`weight_kg`) — a history can genuinely mix units |
| `update_body_weight_entry(entry_id, weight?, when?, unit?, notes?)` / `delete_body_weight_entry(entry_id)` | Edit / remove an entry. Correcting a value keeps the unit it was recorded in; pass `unit` only to restate it, which preserves any provenance a health import left on the entry |

For a trend rather than the entries, `summarize_measurements` works over this category like any other.

### Body measurements

Categories, each with its unit, and the entries that hang off them. Since wger 2.7 a category carries a `metric_type` saying what it holds: the free-form ones a trainee invents (Waist, Chest, Bicep, … all `custom`) sit alongside typed ones for body weight, body fat, height, heart rate, blood pressure, sleep, steps and the rest. Three kinds do not take entries directly — the group containers (`blood_pressure`, `sleep`), whose readings belong in their component children, and the calculated ones (`dynamic_type` other than `NONE`, e.g. BMI), which the server maintains.

| Tool | Description |
|------|-------------|
| `list_measurement_categories(metric_type?, limit?)` / `get_measurement_category(category_id)` | Read categories. `metric_type` goes straight to one (`body_weight`, `body_fat`, `steps`, `sleep_rem`, …) instead of paging the list to find it |
| `create_measurement_category(name, unit?, metric_type?)` | Add a category. Without `metric_type` it is free-form (`custom`), e.g. `name='Bicep'`, `unit='cm'`. With one it is typed (`body_fat`, `height`, `heart_rate`, `steps`, …), which is what lets wger chart it and a health sync write into it; the unit then defaults to the conventional one. One typed category per person, and the type cannot be changed afterwards |
| `update_measurement_category(category_id, name?, unit?)` / `delete_measurement_category(category_id)` | Rename / re-unit a category, or delete it with all its entries |
| `log_measurement(category_id, value, when?, notes?)` | Add an entry. Defaults to now; a bare date lands at 12:00. The range a value may be in follows the category's `metric_type` (20-350 kg for a body weight, 0-100000 for a step count), and wger's refusal names it |
| `list_measurements(category_id?, date_from?, date_to?, source?, limit?)` / `get_measurement(measurement_id)` | Read entries (newest first), optionally per category, date range (both inclusive) and origin. `source` is `user` (hand-entered), `apple` / `google` (phone health sync) or `calculated` (maintained by wger, e.g. BMI) |
| `summarize_measurements(category_id?, date_from?, date_to?, bucket?, max_points?, timezone_name?)` | Condense a series into per-period rows — count, sum, min, max per category, bucket and unit. `bucket` is `auto`, `hour`, `day`, `week` or `month`. A year of daily weigh-ins is 365 entries through `list_measurements` and twelve rows through this. Buckets follow the trainee's own calendar |
| `update_measurement(measurement_id, value?, when?, notes?, category_id?)` / `delete_measurement(measurement_id)` | Edit / remove an entry. `category_id` moves one filed under the wrong category |

#### Blood pressure

A reading is two entries — systolic and diastolic — in two child categories of a `blood_pressure` group, paired by carrying the exact same timestamp. These two tools are what make that workable; doing it through `log_measurement` means finding both categories and matching timestamps by hand.

| Tool | Description |
|------|-------------|
| `log_blood_pressure(systolic, diastolic, when?, notes?)` | Record one reading, writing both halves with one timestamp. Creates the category group the first time, so nothing has to be set up in advance. Refuses a pair passed the wrong way round |
| `get_blood_pressure_history(date_from?, date_to?, limit?)` | Readings (newest first), each as one row with both numbers. A half missing its counterpart is reported with the other side null rather than dropped |

### Exercise catalog

| Tool | Description |
|------|-------------|
| `search_exercises(query, language?, limit?)` | Find exercises by name (ISO 639-1 language code). Returns id, name, category and equipment |
| `search_exercises_batch(queries, language?, limit_per_query?)` | Resolve many names at once, one call instead of one per exercise |
| `search_exercises_by_filter(equipment_id?, muscle_id?, category_id?, language?, limit?)` | Structured lookup (e.g. Dumbbell + Back) |
| `get_exercise(exercise_id)` | Full exercise detail: muscles, equipment, instructions, images (with 2.6 `small`/`medium` thumbnails) |
| `list_categories` / `list_equipment` / `list_muscles` | Reference data |

### Ingredients

| Tool | Description |
|------|-------------|
| `search_ingredients(query, language, limit, nutriscore?, nutriscore_better_than?, nutriscore_at_worst?)` | Find foods by name; returns macros, `fiber` and the `nutriscore` grade. Optional Nutri-Score filters (wger 2.6): exact grade, or `nutriscore_better_than='C'` (A/B only), or `nutriscore_at_worst='C'` (C or better) |
| `search_ingredient_by_barcode(barcode, limit?)` | Exact lookup by EAN/UPC (`?code=`) — preferred over name search |
| `get_ingredient(ingredient_id)` | Full ingredient detail (macros per 100 g) |

> wger's REST `/ingredient/` is **read-only** by design (community-maintained DB), so there is no `create_ingredient` tool. Submitting custom ingredients previously drove wger's Django web form with username/password; that path was dropped with the move to multi-user SSO auth.

### Nutrition plans, meals, recipes, diary

| Tool | Description |
|------|-------------|
| `list_nutrition_plans` / `get_nutrition_plan(plan_id)` | Read nutrition plans |
| `create_nutrition_plan(description?, only_logging?, goal_energy?, goal_protein?, goal_carbohydrates?, goal_fat?, goal_fiber?, start?, end?)` | Create a plan (returns `plan_id`). `start`/`end` date the block; `goal_fiber` sits alongside the macro goals |
| `update_nutrition_plan(plan_id, ...)` / `delete_nutrition_plan(plan_id)` | Patch / delete a plan (cascade) |
| `create_meal(plan_id, name, order?, time?)` | Add a meal to a plan |
| `create_recipe(plan_id, name, order?)` / `get_recipe(recipe_id)` / `add_ingredient_to_recipe(recipe_id, ingredient_id, amount_g, order?, weight_unit_id?)` | Recipes (semantic aliases over `meal/` + `mealitem/` — wger has no separate Recipe entity) |
| `log_ingredient(plan_id, ingredient_id, amount_g, when?, meal_id?, weight_unit_id?)` | Nutrition diary entry. `when` takes a full timestamp (`2026-07-21T07:30:00+02:00`, offset preserved) or a bare date (anchored at 12:00); omit it to let wger use the current time. With `weight_unit_id` the amount counts portions instead of grams — two slices, not two grams; the id is checked against the ingredient first |
| `list_ingredient_units(ingredient_id, limit?)` | The portions wger knows for one ingredient (slice, cup, can) with the grams each weighs — the only way to discover the ids `weight_unit_id` takes |
| `update_log_item(log_item_id, amount_g?, when?, ingredient_id?, meal_id?, weight_unit_id?, plan_id?)` | Patch a diary entry — the way to correct an entry's time or amount in place. `plan_id` moves it to another plan |
| `list_log_items(when?, plan_id?, limit?)` / `delete_log_item(log_item_id)` | List / remove diary entries |
| `nutrition_summary(when?, plan_id?)` | Daily kcal/protein/carbs/fat/fiber from diary entries. Entries logged in a portion unit are scaled by what that unit weighs, as wger does |
| `calculate_daily_calories(weight_kg?, height_cm?, age?, sex?, activity_level?, goal?, protein_g_per_kg?, fat_pct_of_kcal?, apply_to_profile?)` | Mifflin-St Jeor TDEE + macro split. All physical inputs auto-fill from `userprofile/` + latest `weightentry/`. `apply_to_profile=True` PATCHes the result into `userprofile.calories` |

### Analytics

| Tool | Description |
|------|-------------|
| `weekly_summary(days?)` | Aggregate workoutlog: sets, reps, volume per exercise |
| `exercise_history(exercise_id, days?, limit?)` | Per-session aggregates (sets, reps, top weight, volume) for one exercise |
| `personal_records(exercise_id?, days?)` | Max weight, max reps, Epley-estimated 1RM per exercise |
| `volume_trend(days?, bucket, metrics?, group_by?, exercise_id?)` | Bucketed (day/week/month) volume; group_by none/exercise/muscle/category |
| `compare_periods(window_days?, gap_days?, metrics?, group_by?)` | Rolling window A vs B (delta + delta%) |

### Open Food Facts (external food database)

| Tool | Description |
|------|-------------|
| `lookup_food_by_barcode(barcode, language?)` | Resolve an EAN/UPC/GTIN on Open Food Facts. Returns the localised name + ingredients (when present) and macros per 100 g, with a one-shot retry on 429. Salt→sodium conversion applied automatically |
| `lookup_foods_by_barcodes(barcodes[], language?)` | Batch variant — concurrent fetches (capped at 4 in flight). Returns map keyed by barcode |

> Use these when you have a barcode — far more accurate than wger name search. Coverage is good for branded packaged goods and thin for supermarket private-labels, and it varies a lot by country. For items missing on OFF, the response includes a `suggestion` URL to add them — additions flow back into wger via the next ingredient-sync.
>
> **Language.** OFF stores per-language fields (`product_name_<lang>`, `ingredients_text_<lang>`). Which one is requested and preferred comes from `DEFAULT_LANGUAGE` (default `en`), and every tool with a `language` argument overrides it per call. The response echoes the resolved `language` and carries both `name_localized` and the language-neutral `name_default`.

## Configuring a client

### Interactive (MCP-native OAuth)

Point the client at the Streamable HTTP URL. On first use it fetches
`/.well-known/oauth-protected-resource`, runs the OAuth flow against the
provider — wger itself under `wger_oidc` — and attaches the resulting Bearer
token automatically.

```json
{
  "mcpServers": {
    "wger": {
      "type": "streamable-http",
      "url": "https://wger-mcp.example.com/mcp"
    }
  }
}
```

### Scripts / headless (manual Bearer)

Obtain a token out-of-band and pass it as `Authorization: Bearer <token>`.
See `scripts/get_token.py` for a device-flow example.

Under `wger_oidc` that is a wger access token — from any of wger's own grants,
the device-code flow included — and it is used as-is. Under `oidc` it is an IdP
token whose audience must be acceptable to the server (`MCP_OIDC_AUDIENCE`),
which the server then exchanges for a wger credential.

## Deployment

CI publishes a multi-arch image to the GitHub Container Registry on every push to the default branch and on `v*.*.*` tags:

```bash
docker pull ghcr.io/wger-project/mcp-server:latest
```

A reference Docker setup ships in `Dockerfile` and `compose.example.yml`. The server is a single ASGI app (`wger_mcp.server:build_app`) and can also be run under any ASGI host (Hypercorn, Granian, gunicorn-uvicorn, …).

If exposed over HTTPS via a reverse proxy, configure the proxy with:

```nginx
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
```

so that streamable-HTTP/SSE responses aren't buffered.

## Documentation

- [CONTRIBUTING.md](CONTRIBUTING.md) — development setup, project layout, how to add a tool or an auth strategy.
- [docs/api-keys.md](docs/api-keys.md) — which credential goes where, and a `401` troubleshooting table.
- [CONTEXT.md](CONTEXT.md) — glossary of the terms used across these docs.
- [docs/HANDOFF.md](docs/HANDOFF.md) — maintainer notes: non-obvious constraints, open items, and known limitations.
- [docs/adr/](docs/adr/) — architecture decisions and the reasoning behind them:
  - [0001](docs/adr/0001-multi-user-auth-via-oidc-token-exchange.md) — multi-user access via OIDC token exchange
  - [0002](docs/adr/0002-opaque-string-resource-ids.md) — opaque string resource ids
  - [0003](docs/adr/0003-oauth-authorization-server-facade.md) — the OAuth authorization-server facade
  - [0004](docs/adr/0004-static-token-strategy-for-single-user.md) — the `static_token` strategy
  - [0005](docs/adr/0005-native-wger-oidc.md) — native wger OIDC, superseding the transport half of 0001

## Development

```bash
uv sync --dev
uv run pytest        # inbound auth (all four strategies), token exchange, wger client, tools
uv run ruff check .
```

### Source layout

- [`src/wger_mcp/server.py`](src/wger_mcp/server.py) — Starlette + FastMCP wiring, lifespan, healthcheck, OAuth metadata, auth middleware.
- [`src/wger_mcp/api_client.py`](src/wger_mcp/api_client.py) — bridge to the generated [`wger-api-client`](https://pypi.org/project/wger-api-client/): resolves the per-request wger credential from the token provider via a custom httpx auth, plus offset pagination over the generated `*_list` endpoints.
- [`src/wger_mcp/auth/`](src/wger_mcp/auth/) — inbound OIDC validation (`oidc.py`, discovery in `oidc_discovery.py`), token exchange + outbound credential provider (`exchange.py`), per-request identity (`identity.py`), OAuth metadata (`oauth.py`).
- [`src/wger_mcp/tools/`](src/wger_mcp/tools/) — one module per domain. Each exposes `register(mcp, api, settings)`; [`tools/__init__.py`](src/wger_mcp/tools/__init__.py) registers them all.

### Performance notes

- `nutrition_summary`, `list_slot_entry_configs`, `_load_ex_meta` (used by `volume_trend` / `compare_periods`) fan out per-id fetches via `asyncio.gather` + a small `Semaphore`. Concurrency caps live in the tool modules — tune down if your wger instance applies per-token rate limits.
- Exercise metadata is cached process-wide (`_EX_META_CACHE` in [`tools/analytics.py`](src/wger_mcp/tools/analytics.py)) — analytics tools called repeatedly within one process pay the metadata cost only once.
- `compare_periods` issues two range queries in parallel and skips fetching the gap window entirely.

## Upgrading

### Language is now configurable (was hard-coded Polish)

The Open Food Facts tools used to always request the Polish fields
(`product_name_pl`, `ingredients_text_pl`), and the exercise/ingredient search
tools defaulted to `en`. Both now follow `DEFAULT_LANGUAGE` (default `en`), with
a per-call `language` argument overriding it.

- **To keep the previous OFF behaviour**, set `DEFAULT_LANGUAGE=pl`. Otherwise
  barcode lookups return English names where a Polish one used to be preferred.
- **Response keys changed** on `lookup_food_by_barcode` /
  `lookup_foods_by_barcodes`: `name_pl` → `name_localized`,
  `ingredients_text_pl` → `ingredients_text`, plus a new `language` field
  echoing the resolved code. `name`, `name_default` and the macro fields are
  unchanged.

## License

AGPL-3.0-or-later, matching the wger project. See [LICENSE](LICENSE).
