Skip to content

Import the wger MCP server implementation - #1

Merged
rolandgeider merged 35 commits into
masterfrom
import-mcp-server
Aug 12, 2026
Merged

Import the wger MCP server implementation#1
rolandgeider merged 35 commits into
masterfrom
import-mcp-server

Conversation

@PawelHaracz

Copy link
Copy Markdown
Contributor

Hi Roland — this imports the MCP server I've been developing at PawelHaracz/wger-mcp, following the discussion about moving it here.

Opened as a PR rather than pushed to master so you can look before it lands.

What it is

An MCP server exposing the wger REST API as tools, so AI assistants can read and write wger data — routines, workout logging, exercise & ingredient catalog, nutrition plans and diary, body weight, measurements, gym equipment, and volume/PR analytics.

It talks to wger over the public REST API only and needs no changes to wger itself. Requires wger >= 2.6 and Python >= 3.11.

Auth

Three inbound strategies, since one size didn't fit:

Strategy Users Needs an IdP? Safe to expose?
oidc (default) multi-user, each acts as themselves yes yes
static_token single-user (shared wger account) no yes, over TLS
none single-user, unauthenticated no no — localhost only

oidc validates an IdP token, then uses RFC 8693 token exchange plus allauth's headless provider/token to obtain a wger JWT per user, so no per-user secrets are stored. static_token exists because most self-hosters don't run Keycloak, and none isn't safe to expose.

Two things worth your attention

wger-side MFA blocks the headless exchange entirely. If a user has a TOTP/WebAuthn authenticator enrolled in wger, provider/token returns a pending MFA challenge and no JWT — a server-side exchange can't complete it, and there's no setting to skip it. So MFA has to be delegated to the IdP. This is the biggest constraint on the multi-user model and may be worth addressing upstream.

Making wger itself an OIDC provider (via django-allauth) would be the highest-value follow-up. Today multi-user requires a third-party IdP that most self-hosters won't run, so in practice they fall back to single-user. That's a wger-side change, which is why it isn't attempted here. Noted in docs/HANDOFF.md along with the other non-obvious constraints.

Conflict resolution

Merged with --allow-unrelated-histories, so your initial commit stays the root:

  • .gitignore — kept your template, appended only the three entries it didn't already cover (.env.probe, .idea/, .vscode/). Checked each against the existing patterns rather than concatenating.
  • README.md — replaced the stub with the project docs.
  • LICENSE — untouched. I'd already matched your AGPL-3.0 copy byte-for-byte upstream so it wouldn't conflict.

State

  • 71 tests, no network needed (respx mocks all outbound HTTP), passing on Python 3.11 / 3.12 / 3.13
  • ruff check . clean
  • CI targets master; the Docker workflow publishes to ghcr.io/${{ github.repository }}, so it resolves to this repo automatically
  • docs/adr/ records the architecture decisions; docs/HANDOFF.md covers constraints, open items, and known limitations

Version is 0.1.0 and nothing has been tagged, so versioning policy is open. Happy to adjust anything to fit the project's conventions.

PawelHaracz and others added 30 commits May 18, 2026 14:06
MCP (Model Context Protocol) server that exposes the wger fitness/nutrition
REST API as tools (12 total: workouts, routines, exercise search, nutrition
diary, body-weight tracking, weekly volume aggregation, ...).

Transport: Streamable HTTP via FastMCP.
Inbound auth: pluggable — api_key | jwt (any OIDC IdP) | proxy_header (mirrors
wger's own AUTH_PROXY_HEADER model) | none. No vendor lock-in to a specific IdP.
Outbound to wger: DRF API token (single-user).

Includes 28 tests covering all 3 auth strategies plus the httpx wger client.
GitHub Actions CI on Python 3.11/3.12/3.13. AGPL-3.0-or-later to match wger.
- Dockerfile: multistage with uv cache mount, --no-editable install,
  non-root user, HEALTHCHECK on /health. Smoke-tested locally
  (image starts, /health → 200, docker reports healthy).
- .dockerignore: trim build context (excludes .git, .venv, tests, scripts).
- .github/workflows/docker.yml: build & push to ghcr.io on push to main
  (tag: latest + sha-XXXX) and on v*.*.* tags (semver). Multi-arch
  amd64 + arm64 via QEMU/buildx, GHA cache, SBOM + provenance attestation.
  PR builds without pushing.

Remove LICENSE and license metadata for now — to be re-added (aligned with
wger AGPL-3.0-or-later) once handed off upstream.
- Set router.redirect_slashes=False so POST /mcp doesn't issue 307 to
  /mcp/ (clients like curl, Claude Code, Claude Desktop don't follow
  redirects on POST, so the 307 manifested as 'Failed to connect').
- Pass proxy_headers=True + forwarded_allow_ips="*" to uvicorn so
  X-Forwarded-Proto from the upstream proxy is honored. Without this
  uvicorn ignores any X-Forwarded-* header whose peer IP isn't 127.0.0.1
  (the default whitelist), so URL generation defaulted to http:// even
  when the client reached us over https://. Fixes downgraded Location
  headers behind nginx.
New docs/api-keys.md covering:
- difference between MCP_API_KEYS (inbound) and WGER_API_TOKEN (outbound),
- generation (openssl rand -hex 32 for MCP keys, wger UI for the DRF token),
- zero-downtime rotation procedure for MCP keys,
- client setup snippets (Claude Code/Desktop, curl),
- FAQ on why the two credentials are separate,
- security checklist.

README gets a "Two credentials, two roles" section pointing at the doc to
front-load the most common 401 confusion when standing the server up.
…slot_entry_config, and introduce add_exercise_with_sets for streamlined slot and entry creation
… tools

- Implemented body weight logging, history retrieval, and entry management in `body_weight.py`.
- Created shared error handling functions in `common.py`.
- Developed exercise and ingredient search functionalities in `exercises.py`.
- Added nutrition plan management, meal creation, and logging in `nutrition.py`.
- Introduced user profile retrieval in `profile.py`.
- Built comprehensive routine management including days, slots, and exercises in `routines.py`.
- Established workout logging capabilities with set management in `workout_logs.py`.
…T /mcp/ 404

FastMCP.streamable_http_app() registers Route(mcp_path) internally.
Wrapping it in Mount(mcp_path) caused double-prefix: nginx sends POST /mcp/,
Mount strips the prefix leaving "", which never matches the inner Route("/mcp/")
→ 404. Merging mcp_starlette.routes directly into the top-level Starlette fixes
the routing. Also sets streamable_http_path=settings.mcp_path so the path is
configurable via MCP_PATH env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
streamable_http_app() registers only Route("/mcp") (no trailing slash).
With redirect_slashes=False, POST /mcp/ from MCP clients (and the test
suite) returns 404 because Starlette won't auto-match the slashed twin.

Mirror each MCP route with a trailing-slash variant pointing to the same
ASGI endpoint so both /mcp and /mcp/ are first-class entry points.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous twin-route logic only added a trailing-slash variant when the
registered path had none, so MCP_PATH=/mcp/ (the deployed default) registered
only /mcp/ and left bare /mcp returning 404. Toggle the trailing slash in both
directions and register whichever twin isn't already present, so both forms hit
the ASGI app no matter how MCP_PATH is written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wger API returns "repetitions" (string e.g. "8.00"), not "reps",
causing all reps/volume_kg aggregations to be 0. Adds _entry_reps()
helper that reads the correct field with int(float()) coercion and
fixes log_set/update_workout_log to POST "repetitions" as well.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- measurements.py: full CRUD for measurement categories (name/unit)
  and measurement entries (value, date, notes) via /measurement-category/
  and /measurement/ wger API endpoints
- equipment.py: full CRUD for gym equipment via /equipment/ endpoint;
  replaces the read-only list_equipment from exercises.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wger 2.6 is a breaking release. Rework the server for it:

Auth — replace the single-user static DRF token with a multi-user model.
Every request acts as the caller's own wger account:
- inbound: validate an OIDC IdP bearer token against the IdP's JWKS;
  advertise OAuth Protected Resource Metadata for MCP-native OAuth.
- outbound: confidential-client RFC 8693 token-exchange -> wger allauth
  headless provider/token -> wger JWT, per request, cached ~5 min in memory.
- provider-agnostic: JWKS/token endpoints come from OIDC discovery
  ({issuer}/.well-known/openid-configuration). Verified live against Keycloak.
- drop api_key / proxy_header / jwt strategies; keep `none` for local dev
  (static WGER_DEV_TOKEN). Remove the static WGER_API_TOKEN + WgerSession.

wger 2.6 REST changes:
- treat all resource IDs as opaque strings (several models migrated int->UUID).
- remove create_ingredient (REST /ingredient/ is read-only; the web-form path
  needed a per-user password, incompatible with SSO).
- add Nutri-Score range filter to search_ingredients; expose exercise image
  thumbnails.

Notes (validated end-to-end): the IdP must permit token-exchange to wger's
audience (Keycloak: Standard Token Exchange + Audience mapper); the exchange
must yield an access_token aud'd at wger (an id_token is rejected); and MFA
must be delegated to the IdP — wger-side 2FA blocks the headless login.

Docs: README, .env.example, CONTEXT.md, ADR 0001, plus test harness
(scripts/e2e.sh, e2e_call.py, probe_exchange.py, get_token.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: multi-user OIDC SSO auth + wger 2.6 compatibility
Clients like claude.ai treat the MCP origin as the OAuth authorization
server and run discovery + token exchange from the cloud, so a private
(LAN-only) IdP is unreachable and the flow dead-ends at {origin}/authorize.

Front the IdP as a thin AS facade in oidc mode:
- protected-resource + oauth-authorization-server (RFC 8414) metadata
  advertise THIS origin as the authorization server
- /oauth/authorize 302s to the IdP authorization endpoint (front-channel
  browser login); /oauth/token reverse-proxies to the IdP (back-channel)
- the IdP stays private: only this server and the user's browser touch it;
  tokens are still minted/signed by the IdP, so inbound validation and the
  RFC 8693 exchange are unchanged
- derive the public origin from MCP_PUBLIC_URL / X-Forwarded-* for the
  resource id, WWW-Authenticate and the AS metadata
- resolve the IdP authorization_endpoint via discovery
  (OIDC_AUTHORIZATION_ENDPOINT override)

Docs: README facade + claude.ai connector section, ADR 0003, CONTEXT term,
.env.example. Tests: 31 pass (5 new facade tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(auth): OAuth Authorization-Server facade fronting the IdP
claude.ai's MCP connector (and similar clients) treat the MCP origin as the
OAuth authorization server but IGNORE the authorization_endpoint advertised in
the RFC 8414 metadata — they assume the conventional root paths /authorize and
/token relative to the origin. The facade was mounted at /oauth/authorize and
/oauth/token, so the client hit /authorize → inbound auth middleware → 401 and
the flow dead-ended before reaching Keycloak.

- default the facade paths to /authorize and /token (what clients assume)
- add OAUTH_AUTHORIZE_PATH / OAUTH_TOKEN_PATH env overrides (no rebuild needed)
- thread the configured paths through route registration, AS metadata, and the
  inbound-auth bypass (middleware public_paths instead of a hardcoded /oauth/
  prefix)
- tests: update facade paths, add an override+bypass test (32 pass)
- docs: README, CONTEXT, ADR 0003, .env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(auth): serve AS facade at root /authorize + /token, env-configurable
* docs: fix stale /oauth/* facade path refs to /authorize + /token

Follow-up to the root-path change: the facade module/builder docstrings and the
OIDC_AUTHORIZATION_ENDPOINT env comment still referenced the old /oauth/authorize
and /oauth/token paths. The two remaining /oauth/* mentions are intentional
override examples (config.py, .env.example).

* docs: fix one more stale /oauth/* ref in README bypass note
The Open Food Facts tools hard-coded Polish: `product_name_pl` and
`ingredients_text_pl` were baked into the requested field list, the
shaping logic and the response keys. The exercise and ingredient search
tools separately hard-coded an `en` default. Neither was configurable.

Add a `DEFAULT_LANGUAGE` setting (ISO 639-1, default `en`, validated and
normalised) that drives both. Every tool taking a `language` argument now
defaults to it and overrides it per call.

- off.py: build the OFF `fields=` list from templates via `_fields_for(lang)`
  instead of a constant; `_shape(prod, lang)` selects the localised fields and
  falls back to the language-neutral `product_name`.
- exercises.py: `language` defaults to None and resolves to the configured
  default at call time rather than being frozen at registration.
- Thread `settings` through `register_all` into all 10 tool registrars, so
  there is a single source of configuration.
- Normalise list-valued OFF text fields consistently via `_scalar`. Previously
  only `name` collapsed a list to its first entry, so a list-valued
  `product_name_<lang>` could disagree with `name_localized`.

Verified against the live OFF API that `product_name_<lang>` and
`ingredients_text_<lang>` are genuine fields, that an unknown language code
returns 200 with the key simply absent, and that OFF returns `""` rather than
omitting a field for languages it lacks. Both cases fall back correctly.

BREAKING: the OFF tools' response keys change. `name_pl` -> `name_localized`,
`ingredients_text_pl` -> `ingredients_text`, plus a new `language` field. See
the Upgrading section in the README; set `DEFAULT_LANGUAGE=pl` to retain the
previous behaviour.

Also settle the license as AGPL-3.0-or-later to match the wger project: add
the LICENSE text, declare it in pyproject (PEP 639), and replace the
"unspecified for now" notes in the README and CONTRIBUTING.


Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
PawelHaracz and others added 5 commits August 12, 2026 13:00
There were only two inbound strategies: `oidc`, which needs an IdP, and
`none`, which has no inbound gate at all. That left no way to run a
remotely-reachable server without standing up Keycloak or equivalent —
`none` is not a substitute, since anyone who can reach /mcp acts as the
account behind WGER_DEV_TOKEN.

Add `MCP_AUTH=static_token`: callers present MCP_STATIC_TOKEN as a bearer
token, the server validates it, then calls wger with the personal DRF API
key. Single-user like `none`, but authenticated, so it is safe to expose
over TLS.

- StaticTokenMiddleware compares with hmac.compare_digest so a wrong token
  leaks no timing signal, and reuses the shared bypass-path and 401-challenge
  helpers.
- Config requires both secrets and enforces a 32-character minimum on
  MCP_STATIC_TOKEN; the secret is the entire attack surface, so a guessable
  value is refused at startup rather than at first request.
- Do not serve the OAuth discovery documents unless `oidc` is the active
  strategy. The route gate keyed off OIDC_ISSUER alone, so setting both
  MCP_AUTH=static_token and an issuer would have advertised an authorization
  server whose tokens this server never accepts, sending clients through a
  flow that could not succeed.

Docs: README gains a strategy comparison table and rewrites the `none`
section to state plainly that it performs no inbound authentication, rather
than the previous "local dev only" framing which understated it.


Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-homes the documentation for the move to
https://github.com/wger-project/mcp-server and corrects docs that had gone
stale as the auth model changed.

Migration-specific:
- CI triggered only on `main`, but the target repository's default branch is
  `master`, so pushes there would have run no CI. Both workflows now trigger on
  either. Trim to one once the move settles.
- LICENSE now matches the copy already in the target repository byte-for-byte
  (same AGPL-3.0 text, differing only in the appendix line-wrapping) so the
  migration does not produce a spurious diff.
- README documents the published image path and clone URL under the new org;
  `IMAGE_NAME: ${{ github.repository }}` already resolves correctly.

Removed fork-specific content:
- compose.example.yml hard-coded a personal NAS path, home LAN addresses, a
  private domain and a local timezone. Rewritten as a generic example that
  pulls the published image, binds to localhost by default, and explains why
  proxy buffering must be off.
- Test fixtures used a private hostname; now example.com.

Corrected stale documentation:
- docs/api-keys.md described `MCP_API_KEYS`, `WGER_API_TOKEN` and an `api_key`
  strategy, none of which still exist, and was linked from nowhere. Rewritten
  as a credentials reference covering the actual inbound/outbound split, with a
  401 troubleshooting table.
- CONTRIBUTING.md listed auth modules that were deleted (api_key.py, jwt.py,
  proxy_header.py), said tools live in server.py, and quoted a stale test
  count. Rewritten against the real layout.
- CONTEXT.md listed the old strategy names and asserted single-user was removed,
  which static_token has since revisited.

Added:
- ADR 0004 recording the static_token decision, with ADR 0001 marked as
  partially amended by it rather than silently contradicted.
- docs/HANDOFF.md — constraints that cost time to discover (claude.ai ignoring
  the advertised authorization_endpoint, the read-only ingredient API,
  wger-side MFA blocking the headless exchange), open items, and a note that
  making wger itself an OIDC provider is the highest-value follow-up.
- A Documentation section in the README indexing all of the above.


Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #5.

log_ingredient hard-coded the time component of every diary entry:

    "datetime": f"{(when or date.today()).isoformat()}T12:00:00Z"

so `when` was effectively date-only. Passing a full ISO 8601 timestamp failed
pydantic validation ("must match format 'date'"), and passing nothing pinned
the entry to noon UTC — which is what the reporter saw as 14:00 in +02:00,
rather than the server time it appeared to be.

wger's LogItem.datetime is a DateTimeField defaulting to timezone.now, so it
accepts a full timestamp and stamps the entry itself when the field is omitted.
Neither was being used.

- `when` now accepts a datetime or a date. A datetime is forwarded verbatim,
  preserving any offset; a bare date is anchored at 12:00 (not midnight, so a
  timezone shift in either direction keeps the entry on the intended day).
- Omitting `when` now omits `datetime` from the payload entirely, letting wger
  apply its own default instead of an arbitrary hour chosen here.
- Add `update_log_item` to PATCH an existing entry. The endpoint is a
  ModelViewSet so PATCH was always available, but no tool exposed it — and as
  the issue notes, wger's web UI cannot edit an entry's time either, so a wrong
  timestamp previously meant delete-and-recreate.
- Add the optional `meal_id` the serializer accepts, to attribute an entry to a
  specific meal of the plan.

Verified against wger's source (nutrition/models/log.py, api/serializers.py)
rather than the rendered API docs, and end-to-end with the exact call from the
issue report.


Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dual [main, master] trigger existed only to keep CI alive across the
migration. Now that the destination is known, narrow it to master.


Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the implementation developed at PawelHaracz/wger-mcp: an MCP server
exposing the wger REST API as tools over Streamable HTTP, with three inbound
auth strategies (multi-user OIDC SSO, single-user static token, and an
unauthenticated localhost-only mode).

Conflict resolution against the initial commit:

- .gitignore — kept the existing template, appended only the three entries it
  did not already cover (.env.probe, .idea/, .vscode/).
- README.md — replaced the stub with the project documentation.
- LICENSE — unchanged; the imported tree already matched it byte-for-byte.

See docs/HANDOFF.md for constraints that are not obvious from the code, and
docs/adr/ for the architecture decisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgtTspkxXUgLVjPmqt6PnX
@rolandgeider

rolandgeider commented Aug 12, 2026

Copy link
Copy Markdown
Member

awesome thanks! This can definitely be merged, everything else can come afterwards

Making wger itself an OIDC provider

I'm not sure if I didn't mention it in the other issue, but this was already merged

@rolandgeider
rolandgeider merged commit 369511a into master Aug 12, 2026
4 checks passed
@PawelHaracz

Copy link
Copy Markdown
Contributor Author

Sure, I will polish little bit it and will start working on oidc

@rolandgeider

Copy link
Copy Markdown
Member

I would like to work on using the python client to talk to the API

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants