docs: add rewritten CLAUDE.md (force-add; it is gitignored)
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 30s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 07:23:12 -04:00
parent 8fee4717dd
commit f7f41013cb
+160
View File
@@ -0,0 +1,160 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
The MCP server in a self-hosted, **multi-tenant** "Intervals.icu MCP" system running on Kubernetes
(Flux GitOps) behind a single host, `https://intervalsicu.farhoodlabs.com`. This is a fork of the
upstream single-tenant project (github.com/mvilanova/intervals-mcp-server) that has been reworked for
remote, OAuth-authenticated, per-user operation.
It exposes the Intervals.icu REST API (activities, events, wellness, power curves, gear, custom items)
as MCP tools over streamable-HTTP at `/mcp`. Python 3.12+, built on FastMCP (from `mcp[cli]`) + httpx,
with SQLAlchemy/asyncpg + Alembic for the user store and PyJWT for token verification. Managed with `uv`.
Three services share one CloudNativePG Postgres database on that host:
- **intervalsicu-mcp** (this repo) — the MCP server at `/mcp`.
- **intervalsicu-mcp-ui** — a portal at `/portal` where users sign in (Google via Better Auth) and set
their Intervals.icu credentials, and admins approve accounts.
- **intervalsicu-mcp-auth** — a TypeScript Better Auth OAuth 2.1 server at `/api/auth` (DCR per RFC 7591,
PKCE, EdDSA-signed JWT access tokens + JWKS, Google login). Claude's connector self-registers here.
**Runtime auth flow:** Claude connector → dynamic client registration at the auth server → Google login
→ JWT access token (audience = the `/mcp` resource URL) → this server verifies the JWT against the auth
server's JWKS and resolves the caller's stored Intervals.icu credentials by the token `subject`. If the
user has no approved-and-populated credentials, tools return a friendly "account isn't ready" message.
## Commands
```bash
uv venv --python 3.12 && source .venv/bin/activate
uv sync --all-extras # install incl. dev extras
uv run pytest # run all tests (enforces a >=90% coverage gate; see pyproject)
uv run pytest tests/test_server.py::test_name # single test
ruff check . # lint
mypy src tests # type check
```
`ruff check .`, `mypy src tests`, and `pytest` must all pass before committing. Gitea CI
(`.gitea/workflows/build.yaml`) installs with `pip install -e ".[dev]"`, runs `pytest` (which enforces
the coverage gate), then builds and pushes the image to `git.farh.net/farhoodlabs/intervalsicu-mcp`;
Flux deploys it. If you change dependencies, run `uv lock` and commit `pyproject.toml` + `uv.lock`
together. For code-only changes leave `uv.lock` untouched.
## Architecture
**Registration is import-side-effect driven.** There is no central registry call. `mcp_instance.py`
creates the single shared `FastMCP` instance; every tool module decorates its functions with
`@mcp.tool()`, so a tool only exists once its module is imported. `server.py` imports all tool modules
at module load (the `# noqa: E402` imports near the bottom) purely to trigger those decorators. If you
add a new tool module, you must import it in `server.py` or its tools silently won't register.
`register_tools()` in `tools/__init__.py` is a no-op kept for API compatibility.
**Import layering avoids cycles.** Tool modules import `mcp` from `mcp_instance.py`, never from
`server.py`. `mcp_instance.py` imports the lifespan (`setup_api_client`) from `api/client.py` and the
auth builder from `auth.py`. Keep this direction: `server.py` → tools → `mcp_instance``api/client`.
**All HTTP to Intervals.icu goes through one function.** `api/client.py::make_intervals_request(url,
api_key, params, method, data)` is the only path out. It uses HTTP Basic auth (username literally
`"API_KEY"`, password = the per-user key), a shared lazily-created `httpx.AsyncClient`, 30s timeout, and
returns either parsed JSON or an error dict `{"error": True, "status_code": int, "message": str}`. Tools
must check for that error shape before formatting. The shared client is closed by the FastMCP `lifespan`
(`setup_api_client`), which also handles test monkeypatching via `server.httpx_client`.
**Tools return formatted strings, not raw JSON.** Each `@mcp.tool()` is an `async def -> str`. The
per-tool pattern: resolve the caller's `(athlete_id, api_key)` via
`credentials.resolve_caller_credentials()` (catching `CredentialError` and returning its message),
validate inputs, call `make_intervals_request`, error-check, then render human-readable text via helpers
in `utils/formatting.py`. Because the return type is `str`, FastMCP auto-generates an `outputSchema`
wrapping the result — this is expected.
### Multi-tenancy: how a caller's credentials are resolved
- `credentials.py::resolve_caller_credentials()` is what tools call. It reads the OAuth access token from
the request context (`get_access_token()`); if there is a `subject`, it looks up that user's active
credentials in the store. With no auth context (stdio / local dev / tests) it falls back to the
`API_KEY` / `ATHLETE_ID` env config, so single-user local runs still work. On failure it raises
`CredentialError`, whose message is safe to show the user ("account isn't ready", or "not authenticated").
- `store.py` — async data access over the `User` model. `get_active_credentials(sub)` returns
`(athlete_id, api_key)` only for an **enabled** user that actually has credentials, decrypting the key
on the way out. Also has the admin/UI write helpers (`upsert_login`, `set_credentials`, `set_enabled`,
etc.). Plaintext API keys are never persisted.
- `crypto.py` — AES-256-GCM authenticated encryption of the stored API key. Key comes from
`INTERVALS_ENC_KEY` (base64 32 bytes, mounted from a k8s secret, shared with the UI so both read/write
the same ciphertext). Layout: `nonce(12) || ciphertext+tag`; reversible (the server needs the key to
call Intervals), not a hash.
- `db/models.py` — the `users` table (`sub` PK = OAuth subject, `email`, `name`, optional `athlete_id` +
encrypted `api_key_enc`, `enabled` gate defaulting to false, timestamps). `has_credentials` property.
- `db/session.py` — lazily-created async engine/sessionmaker from `DATABASE_URL`
(`postgresql+asyncpg://…`). Lazy so importing never requires a database (tests/stdio don't connect);
`configure()`/`reset()` let tests point at aiosqlite.
- Schema is managed by Alembic (`alembic/`, `alembic.ini`); the deployment runs `alembic upgrade head`
in an initContainer (Alembic is copied into the image for this).
### Auth: JWT verification
`auth.py` holds the verifier. `build_auth()` returns `(AuthSettings, TokenVerifier)` only when
`MCP_ISSUER`, `MCP_RESOURCE`, and `MCP_JWKS_URI` are all set; otherwise `(None, None)` and the server
runs unauthenticated (stdio / dev / tests). `AuthentikTokenVerifier` validates the Bearer JWT against
the JWKS (`PyJWKClient`), accepting algorithms `EdDSA`, `RS256`, `ES256` (Better Auth signs EdDSA). It
verifies signature + issuer + `exp`/`iat` strictly, but the **audience check is soft** (logged, not
rejected) because this is a single-resource server behind a dedicated auth server with dynamic
(DCR-issued) client ids. Env:
- `MCP_ISSUER` — expected token issuer (the auth server).
- `MCP_RESOURCE` — the `/mcp` resource URL; drives path-scoped RFC 9728 protected-resource metadata and
the accepted audiences (both trailing-slash forms, plus optional `MCP_CLIENT_ID`).
- `MCP_JWKS_URI` — JWKS endpoint. In-cluster this is an internal URL so verification bypasses Cloudflare.
### Transport
`mcp_instance.py` builds the `FastMCP` instance from env; `server_setup.py` starts it.
- `MCP_TRANSPORT``stdio` (default), `sse`, or `http`/`streamable-http` (deployment uses HTTP).
- `MCP_STATELESS_HTTP` / `MCP_JSON_RESPONSE` — HTTP tuning (defaults false = stateful + SSE, which is
what a single Claude Desktop session wants). **The Kubernetes deployment sets both true (stateless +
JSON):** the distributed Claude connector can hit any replica and can't rely on `Mcp-Session-Id`
session affinity, so stateful/SSE breaks it.
- `FASTMCP_HOST` / `FASTMCP_PORT` — bind address for HTTP/SSE.
- `server_setup.py` adds CORS middleware to the streamable-http app so browser-based connector setup can
reach `/mcp`: `CORSMiddleware` answers the `OPTIONS` preflight directly (200) instead of the auth layer
rejecting it (401). Bearer-token auth with no cookies, so a wildcard origin is safe; it exposes
`Mcp-Session-Id` and `WWW-Authenticate`.
### Config
`config.py::get_config()` reads `API_KEY`, `ATHLETE_ID`, `INTERVALS_API_BASE_URL` from env
(`.env` auto-loaded) — this is only the **local/dev fallback** identity, not the multi-tenant path.
`ATHLETE_ID` must match `r"i?\d+"` (see `utils/validation.py`), validated at server startup, not import.
### Layout
- `mcp_instance.py` — the shared `FastMCP` instance; wires transport tuning + auth from env.
- `server.py` — entry point; wires config, imports tools to register them, starts transport.
- `server_setup.py` — reads `MCP_TRANSPORT`, starts the chosen transport, adds CORS for streamable-http.
- `auth.py` — JWT/JWKS verification and `AuthSettings` construction.
- `credentials.py` / `store.py` / `crypto.py` / `db/` — the multi-tenant credential store.
- `tools/` — one module per domain: `activities`, `events`, `custom_items`, `power_curves`, `gear`, `wellness`.
- `api/client.py` — Intervals.icu HTTP client, Basic auth, error mapping, lifespan.
- `utils/``formatting.py`, `validation.py`, `dates.py`, `types.py` (enums incl. `TransportAliases`,
and workout `Value`/step dataclasses used by event tools).
## Deployment / connecting
This is a **remote OAuth MCP connector**, not a local stdio server. To use it, add a custom remote
connector in Claude pointing at `https://intervalsicu.farhoodlabs.com/mcp`. **Name the connector without
a dot** (use `intervals`, not `intervals.icu`) — Claude Desktop/mobile won't load the tools if the name
contains a dot. Claude then runs DCR against the auth server and Google login; the user must have been
approved in the portal and have set their Intervals.icu credentials.
## Testing conventions
Tests never hit the real API or a real database. Async tools use `@pytest.mark.asyncio`; Intervals HTTP
is stubbed with `pytest-mock` (patch `httpx.AsyncClient` or monkeypatch `server.httpx_client`); the store
is tested against aiosqlite via `db/session.py::configure`. Shared fixtures/mock payloads live in
`tests/sample_data.py`. Cover both the success path and the `{"error": True, ...}` / `CredentialError`
paths. `pytest` enforces a 90% coverage gate.
## Note on stale docs
`.cursor/rules/*.mdc` and `AGENTS.md` predate the multi-tenant refactor (they describe the upstream
single-tenant stdio server and claim all tools live in `server.py`). Trust this file and the code.