11 KiB
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
/portalwhere 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
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 asubject, it looks up that user's active credentials in the store. With no auth context (stdio / local dev / tests) it falls back to theAPI_KEY/ATHLETE_IDenv config, so single-user local runs still work. On failure it raisesCredentialError, whose message is safe to show the user ("account isn't ready", or "not authenticated").store.py— async data access over theUsermodel.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 fromINTERVALS_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— theuserstable (subPK = OAuth subject,email,name, optionalathlete_id+ encryptedapi_key_enc,enabledgate defaulting to false, timestamps).has_credentialsproperty.db/session.py— lazily-created async engine/sessionmaker fromDATABASE_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 runsalembic upgrade headin 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/mcpresource URL; drives path-scoped RFC 9728 protected-resource metadata and the accepted audiences (both trailing-slash forms, plus optionalMCP_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, orhttp/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 onMcp-Session-Idsession affinity, so stateful/SSE breaks it.FASTMCP_HOST/FASTMCP_PORT— bind address for HTTP/SSE.server_setup.pyadds CORS middleware to the streamable-http app so browser-based connector setup can reach/mcp:CORSMiddlewareanswers theOPTIONSpreflight directly (200) instead of the auth layer rejecting it (401). Bearer-token auth with no cookies, so a wildcard origin is safe; it exposesMcp-Session-IdandWWW-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 sharedFastMCPinstance; wires transport tuning + auth from env.server.py— entry point; wires config, imports tools to register them, starts transport.server_setup.py— readsMCP_TRANSPORT, starts the chosen transport, adds CORS for streamable-http.auth.py— JWT/JWKS verification andAuthSettingsconstruction.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 workoutValue/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.