Files
Chris Farhood 674485f275
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 24s
docs: add CLAUDE.md + README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:21:22 -04:00

82 lines
3.8 KiB
Markdown

# 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`.