# CLAUDE.md β€” Nike 🐾 A Heluca repo (project #205). Orientation before action; verify before asserting. The universal standards in `~/.claude/CLAUDE.md` apply; this file records what is **specific to Nike** and where it diverges from the other FastAPI/MCP apps in the estate (Hecate, Periplus, Argos). ## What Nike is Nike is a **football (soccer) data platform** β€” a read-through cache and MCP front-end over the external **TheSportsDB** API, with a SvelteKit dashboard. One FastAPI process on `0.0.0.0:{PORT}` hosts three surfaces: - **`/`** β€” the SvelteKit dashboard (`dashboard/build`, static SPA). - **`/api/*`** β€” JSON API the dashboard consumes (status, tools, logs, tool-runner, telemetry). - **`/mcp`** β€” FastMCP streamable-HTTP endpoint; the eight football tools. Data flow: an MCP tool calls `sportsdb.py` (the TheSportsDB client), which is fronted by a **two-layer cache** β€” a short-lived in-memory TTL cache in `sportsdb.py` and a **PostgreSQL cache** in `db.py` for permanent data (teams, players, leagues, events). The tool formats the result as text for the LLM. Shape-wise Nike is a cousin of Hecate/Periplus (FastAPI + FastMCP + SvelteKit + PostgreSQL + HAProxy + systemd), but three things set it apart and drive most of the rules below: it is **a cache in front of a third-party API**, its **tools are synchronous and return plain strings**, and its **config/DB/HTTP stack is the sync `os.getenv` + `psycopg2` + `requests` generation**, not pydantic-settings/SQLAlchemy/httpx. Modules: `config.py` (settings), `sportsdb.py` (external client + TTL cache), `db.py` (PG pool + cache helpers), `logging_config.py`, `server.py` (FastAPI + all MCP tools + routes). `schema.sql` is the cache DDL. Keep it this flat. ## Red Panda Approvalβ„’ Canonical rubric: [docs/red_panda_standards.md](docs/red_panda_standards.md). The README's emoji "under 500 lines 🚨" box is not the standard. Nike is already one of the more estate-conformant apps β€” hold that bar: 1. **Fresh Environment Test** β€” from a clean checkout: `pip install -e .`, `scripts/apply_schema.py` provisions the cache DB, `cd dashboard && npm run build`, then `python run.py` serves dashboard + MCP. The dashboard **must be built** before FastAPI can serve it (unbuilt β†’ the index route returns a 503 with build instructions, by design). A schema change must apply cleanly on a fresh DB. 2. **Elegant Simplicity** β€” one client function per TheSportsDB endpoint; tools compose them. Don't inline a second `requests` call or a second cache in a tool. 3. **Observable & Debuggable** β€” structured JSON logging (`logging_config.py`), `nike_*` Prometheus metrics, an in-memory request log (`/api/logs`), and the dashboard status cards. A tool logs its name + args + duration via `_log(...)`. 4. **Consistent Patterns** β€” new tools look like the eight existing: `@mcp.tool( annotations=ToolAnnotations(readOnlyHint=True))`, synchronous `def`, resolve team β†’ call `sportsdb` β†’ format a text table, `_log(...)` before returning. 5. **Actually Works** β€” against the real TheSportsDB (free key `3` for basic, premium for the β˜… tools). The free key returns limited data; verify premium-gated tools with an actual premium key or acknowledge you couldn't. ## The landmines (what breaks if you don't know it) - **Tools return plain formatted `str`, not JSON and not Pydantic models.** Nike's tools build human-readable ASCII tables/blocks for the LLM (`"=== {team} ==="`, aligned standings columns). This is a *third* return convention in the estate β€” don't "fix" a tool to return JSON or a model. Match the existing formatting style. See [.claude/rules/mcp-tools.md](.claude/rules/mcp-tools.md). - **Tools are synchronous `def`** even though the app is async β€” `sportsdb` (`requests`) and `db` (`psycopg2`) are blocking. Don't convert tools to `async`. (FastMCP runs them in a threadpool.) The `/api/run` tool-runner calls `tool.fn(**args)` directly β€” keep tools plain callables. - **Read-through cache: permanent vs volatile.** Permanent data (teams, players, leagues, events) is cached in PostgreSQL β€” `_resolve_team` checks the DB first, hits the API on a miss, then caches. **Volatile data is always fetched live and never cached to PG**: standings, livescores, current fixtures. Don't add standings/livescores to the PG cache, and don't skip the cache for team/player lookups. See [.claude/rules/cache-api.md](.claude/rules/cache-api.md). - **Rows come in two shapes.** A team/player dict may be a **raw TheSportsDB dict** (`strTeam`, `idTeam`, `strLeague`) or a **cached DB row** (`name`, `id`, `league_name`). The tools read both with `x.get("strTeam") or x.get("name")`. Preserve that dual-shape handling when you touch formatting β€” a cache hit and a cache miss must render identically. - **Free vs premium key gate.** `config.SPORTSDB_KEY in ('3', '')` means "free key." Premium-only tools (`get_match_detail`, `get_livescores`, V2 squad lists) check this and return a clear "requires a premium key" message, or degrade to cached data (`get_roster` falls back to the DB roster). Premium tools are also `tags={"premium"}` so the dashboard can flag them. Keep both the runtime check and the tag in sync when adding a premium tool. - **Per-API-call `try/except … pass` is deliberate.** Each external call inside a tool is individually guarded so a partial failure yields partial results, not a dead tool. Don't "clean this up" into one big try or into hard failures β€” a missing fixtures endpoint shouldn't blank the whole team page. (It's the one place broad `except` is correct; elsewhere, catch narrowly and log.) - **The tool catalogue is derived, single-source.** `/api/tools` and the MCP `instructions`/`football_analyst` prompt all enumerate the tools; the dashboard reads `/api/tools` (from `mcp.list_tools()`). When you add/rename a tool, the catalogue updates itself β€” but **update the human-written `instructions` string and the `football_analyst` prompt by hand**, they don't. ## Observability & edge (the estate-conformant parts β€” keep them) - **`/metrics` is Prometheus exposition, IP-restricted in Python.** `_METRICS_ALLOWED_NETS` = `10.10.0.0/24`, `172.16.0.0/12`, `127.0.0.0/8`, `::1/128` (the standard four ranges) β€” no auth. The middleware **skips** `/live`, `/ready`, `/metrics`, `/mcp` from request metrics (health probes must not inflate counters β€” `red_panda_standards.md`). Keep both. - **Health routes register both slash forms** (`/live` + `/live/`, `/ready` + `/ready/`). `/ready` checks the DB and returns 503 if it's down. - **Structured logging is re-applied inside the lifespan** (`configure_logging` again after `db.create_pool`) because uvicorn's `config.load()` overwrites handlers β€” the estate FastAPI logging landmine. Don't remove the second call. - **HAProxy is the edge.** `proxy_headers=True` + `forwarded_allow_ips= TRUSTED_PROXY_IPS`. The `/api/*` write routes are **not individually authenticated** β€” access control is at HAProxy. If Nike is ever exposed directly, those write endpoints (`/api/run`, `/api/cache/invalidate`) need protection. The telemetry endpoint (`/api/v1/telemetry`) is intentionally unprotected (`sendBeacon` can't set headers). ## Config & deploy - **Config is `os.getenv` + `python-dotenv`, NOT pydantic-settings.** `config.py` loads `.env` from the repo root and reads `NIKE_*` vars into module constants. A new knob = a new `os.getenv("NIKE_...", default)` **plus** a line in `.env.example`. Do **not** introduce a `Settings` model. `.env` is gitignored (correctly β€” only `.env.example` is tracked); never commit real secrets. - **DB access is `psycopg2` with a `ThreadedConnectionPool`**, checked out via the `get_conn()` contextmanager (commit-on-success / rollback-on-exception). No SQLAlchemy, no async DB. New queries use `get_conn()` and parameterised SQL. - **Ships as systemd** (`nike.service`) and also has a `Dockerfile` + `docker-compose.yml`. See [.claude/rules/config-deploy.md](.claude/rules/config-deploy.md) for the drift in the checked-in unit. ## README & code drift β€” trust the code - The health-check prose is Django/K8s boilerplate: `/ready/` does not "validate cache connectivity" and `/live/` has nothing to do with "Django responding." `/ready` checks the PG cache DB; `/live` is a bare 200. - The emoji file-size rubric is not the real standard. When README and code disagree, the code wins. Fixing the README is worthwhile β€” flag it, don't fold it in. ## Known liabilities (flag, don't silently fix) Pre-existing; surface them, let Robert decide: - **`db.check_connection()` opens a fresh `psycopg2.connect` outside the pool** on every call β€” and `/ready`, `/metrics`, and `/api/status` all call it. The pool's hottest paths bypass the pool. A pooled `SELECT 1` would be cheaper. - **`nike.service` has a stale `WorkingDirectory`/`ExecStart`** pointing at `/home/robert/gitea/nike` (the repo lives at `~/git/nike`) β€” the checked-in unit wouldn't start as-is. - **`.DS_Store` is tracked** at the repo root and in `nike/`. macOS cruft β€” should be gitignored and removed. ## Path-scoped rules (`.claude/rules/`) - `mcp-tools.md` β€” sync tools, string-table returns, premium gating, `_log`, dual dict shapes. - `cache-api.md` β€” read-through pattern, permanent-vs-volatile, TTL + PG layers, per-call try/except. - `db.md` β€” psycopg2 pool, get_conn contextmanager, cache-table upserts, schema.sql. - `fastapi-dashboard.md` β€” /api routes, tool-runner, metrics middleware, health routes, HAProxy edge, mount order. - `svelte.md` β€” static SPA, derived tool list, telemetry, DaisyUI, no server side. - `config-deploy.md` β€” os.getenv config, NIKE_ prefix, systemd + docker, fresh-env build order.