# intervalsicu-mcp-ui Web portal for the self-hosted, multi-tenant **Intervals.icu MCP** system. Users sign in with Google, connect their Intervals.icu account by entering their athlete ID and API key (validated live, then stored **encrypted**), and get the setup steps for the MCP connector. Admins approve accounts before the connector will work for a given user. ## Where it fits Three services share one host (`https://intervalsicu.farhoodlabs.com`) and one Postgres database: | Service | Path | Role | | --- | --- | --- | | intervalsicu-mcp | `/mcp` | FastMCP server exposing Intervals.icu data as MCP tools (multi-tenant) | | **intervalsicu-mcp-ui** (this repo) | `/portal` | Sign-in + credential management + admin approval | | intervalsicu-mcp-auth | `/api/auth` | Better Auth OAuth 2.1 / OIDC provider (Google upstream) | The portal authenticates as an **OIDC client of intervalsicu-mcp-auth** (public client, PKCE, `token_endpoint_auth_method=none`). It writes each user's encrypted Intervals.icu API key into the shared `users` table; the MCP server reads that same table, decrypts the key, and calls Intervals.icu on the user's behalf. ## Shared coupling with the MCP server (read this) The portal and the MCP server share the **same database and the same encryption key**: - The `users` table schema is **owned by the MCP server's Alembic migrations**. `db.py` here only *maps* it (matching column names/types). Test runs build a throwaway SQLite copy from that mapping. - API keys are encrypted with **AES-256-GCM** (`crypto.py`), layout `nonce(12) || ct+tag`, key from base64 `INTERVALS_ENC_KEY`. The MCP server uses the **identical scheme and key**. Change either the format or the key and the other service can no longer decrypt. ## How it works - **Sign in** (`/auth/login` → `/auth/callback`): OIDC against `intervalsicu-mcp-auth`. First login creates a **disabled** user record keyed on the OAuth subject (`sub`). - **Account** (`/account`): the user submits athlete ID + API key. `intervals.py` validates them with a live `GET /athlete/{id}` (HTTP Basic, username `API_KEY`); only on success is the key encrypted and stored. The page also shows the MCP connector URL and Claude setup steps. - **Admin** (`/admin`): emails in `ADMIN_EMAILS` can approve (`enable`), `disable`, or `delete` users. `enabled` is the gate the MCP server checks — until an admin approves, the connector won't work for that user. - `/healthz` returns `{"status": "ok"}`. ## Stack FastAPI + Uvicorn, Authlib (OIDC), Jinja2 templates, SQLAlchemy async + asyncpg (Postgres), `cryptography` (AES-GCM). Python 3.12+. ## Development ```bash uv sync --extra dev uv run pytest # tests + 80% coverage gate (SQLite + stubbed HTTP; no network) uv run ruff check . # run locally (set the env vars below first); --factory targets app.create_app uv run uvicorn intervalsicu_mcp_ui.app:create_app --factory --host 0.0.0.0 --port 8080 ``` ## Environment variables Required: `DATABASE_URL` (e.g. `postgresql+asyncpg://…`), `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URL`, `SESSION_SECRET`, `INTERVALS_ENC_KEY` (base64 of 32 bytes; shared with the MCP server). Optional: `OIDC_CLIENT_SECRET` (leave empty for the public/PKCE client), `ADMIN_EMAILS` (comma-separated), `INTERVALS_API_BASE_URL` (default `https://intervals.icu/api/v1`), `OIDC_SCOPES` (default `openid email profile`), `ROOT_PATH` (e.g. `/portal`), `MCP_URL` (connector URL shown to users). Generate an encryption key with `python -c "from intervalsicu_mcp_ui.crypto import generate_key_b64; print(generate_key_b64())"`. ## Deployment Push to `main` triggers Gitea Actions (`.gitea/workflows/build.yaml` at `git.farh.net`): run tests, then build and push a Docker image to `git.farh.net/farhoodlabs/intervalsicu-mcp-ui`. Flux deploys it to Kubernetes, served behind the gateway under `/portal`.