Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
userstable schema is owned by the MCP server's Alembic migrations.db.pyhere 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), layoutnonce(12) || ct+tag, key from base64INTERVALS_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 againstintervalsicu-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.pyvalidates them with a liveGET /athlete/{id}(HTTP Basic, usernameAPI_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 inADMIN_EMAILScan approve (enable),disable, ordeleteusers.enabledis the gate the MCP server checks — until an admin approves, the connector won't work for that user. /healthzreturns{"status": "ok"}.
Stack
FastAPI + Uvicorn, Authlib (OIDC), Jinja2 templates, SQLAlchemy async + asyncpg (Postgres),
cryptography (AES-GCM). Python 3.12+.
Development
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.