9.7 KiB
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. 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:
- Fresh Environment Test — from a clean checkout:
pip install -e .,scripts/apply_schema.pyprovisions the cache DB,cd dashboard && npm run build, thenpython run.pyserves 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. - Elegant Simplicity — one client function per TheSportsDB endpoint; tools
compose them. Don't inline a second
requestscall or a second cache in a tool. - 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(...). - Consistent Patterns — new tools look like the eight existing:
@mcp.tool( annotations=ToolAnnotations(readOnlyHint=True)), synchronousdef, resolve team → callsportsdb→ format a text table,_log(...)before returning. - Actually Works — against the real TheSportsDB (free key
3for 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. -
Tools are synchronous
defeven though the app is async —sportsdb(requests) anddb(psycopg2) are blocking. Don't convert tools toasync. (FastMCP runs them in a threadpool.) The/api/runtool-runner callstool.fn(**args)directly — keep tools plain callables. -
Read-through cache: permanent vs volatile. Permanent data (teams, players, leagues, events) is cached in PostgreSQL —
_resolve_teamchecks 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. -
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 withx.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_rosterfalls back to the DB roster). Premium tools are alsotags={"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 … passis 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 broadexceptis correct; elsewhere, catch narrowly and log.) -
The tool catalogue is derived, single-source.
/api/toolsand the MCPinstructions/football_analystprompt all enumerate the tools; the dashboard reads/api/tools(frommcp.list_tools()). When you add/rename a tool, the catalogue updates itself — but update the human-writteninstructionsstring and thefootball_analystprompt by hand, they don't.
Observability & edge (the estate-conformant parts — keep them)
/metricsis 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,/mcpfrom request metrics (health probes must not inflate counters —red_panda_standards.md). Keep both.- Health routes register both slash forms (
/live+/live/,/ready+/ready/)./readychecks the DB and returns 503 if it's down. - Structured logging is re-applied inside the lifespan (
configure_loggingagain afterdb.create_pool) because uvicorn'sconfig.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 (sendBeaconcan't set headers).
Config & deploy
- Config is
os.getenv+python-dotenv, NOT pydantic-settings.config.pyloads.envfrom the repo root and readsNIKE_*vars into module constants. A new knob = a newos.getenv("NIKE_...", default)plus a line in.env.example. Do not introduce aSettingsmodel..envis gitignored (correctly — only.env.exampleis tracked); never commit real secrets. - DB access is
psycopg2with aThreadedConnectionPool, checked out via theget_conn()contextmanager (commit-on-success / rollback-on-exception). No SQLAlchemy, no async DB. New queries useget_conn()and parameterised SQL. - Ships as systemd (
nike.service) and also has aDockerfile+docker-compose.yml. See .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."/readychecks the PG cache DB;/liveis 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 freshpsycopg2.connectoutside the pool on every call — and/ready,/metrics, and/api/statusall call it. The pool's hottest paths bypass the pool. A pooledSELECT 1would be cheaper.nike.servicehas a staleWorkingDirectory/ExecStartpointing at/home/robert/gitea/nike(the repo lives at~/git/nike) — the checked-in unit wouldn't start as-is..DS_Storeis tracked at the repo root and innike/. 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.