82 lines
3.8 KiB
Markdown
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`.
|