Import the wger MCP server implementation - #1
Merged
Conversation
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
…hape response data for improved clarity
…, and body weight management
… 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`.
…e update functionality
…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>
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
Member
|
awesome thanks! This can definitely be merged, everything else can come afterwards
I'm not sure if I didn't mention it in the other issue, but this was already merged |
Contributor
Author
|
Sure, I will polish little bit it and will start working on oidc |
Member
|
I would like to work on using the python client to talk to the API |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
masterso 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:
oidc(default)static_tokennoneoidcvalidates an IdP token, then uses RFC 8693 token exchange plus allauth's headlessprovider/tokento obtain a wger JWT per user, so no per-user secrets are stored.static_tokenexists because most self-hosters don't run Keycloak, andnoneisn'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/tokenreturns 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.mdalong 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
respxmocks all outbound HTTP), passing on Python 3.11 / 3.12 / 3.13ruff check .cleanmaster; the Docker workflow publishes toghcr.io/${{ github.repository }}, so it resolves to this repo automaticallydocs/adr/records the architecture decisions;docs/HANDOFF.mdcovers constraints, open items, and known limitationsVersion is
0.1.0and nothing has been tagged, so versioning policy is open. Happy to adjust anything to fit the project's conventions.