Add an opt-in streamable-HTTP transport alongside the default stdio, so familiar can be reached from a home lab over HTTPS behind a TLS reverse proxy. - config.http_settings(): env-driven transport/host/port/path/token settings. - serve.run(): dispatch stdio vs http; fail closed on a non-loopback bind without a bearer token (FAMILIAR_ALLOW_INSECURE_BIND escape hatch). - server.build_http_auth(): FastMCP StaticTokenVerifier bearer gate; add an unauthenticated /healthz route for container/proxy probes. - `familiar serve` CLI alias; clean exit on InsecureBind. - docker/Dockerfile (multi-stage, non-root) + hardened docker-compose.yml (read-only rootfs, cap_drop ALL, no-new-privileges, Traefik TLS labels), .dockerignore, .env.example. Token store mounts at /data; writes off by default. - .gitea/workflows/docker.yml: build + smoke-test, opt-in image publish. - docs/deployment.md + README/CLAUDE.md notes; tests for settings, fail-closed validation, and the bearer verifier.
52 lines
6.7 KiB
Markdown
52 lines
6.7 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## What this is
|
|
|
|
`familiar` is an [MCP](https://modelcontextprotocol.io) server giving a local AI a window into an EVE Online account and the New Eden universe — public market/universe lookups plus authenticated character **and corp** data, with opt-in write actions. Python + [FastMCP](https://github.com/jlowin/fastmcp), managed with `uv`.
|
|
|
|
Note: the project began "read-only by design" but is now **read + write** (writes gated behind safety, see below). The README's older read-only framing is superseded.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # install deps (incl. dev group)
|
|
uv run pytest # full suite; FAILS under 80% coverage (configured in pyproject addopts)
|
|
uv run pytest tests/test_market.py::test_get_market_price # single test
|
|
uv run ruff format . # format
|
|
uv run ruff check . # lint (run both before committing; CI enforces them)
|
|
uv run familiar # run the MCP server (stdio by default; HTTP via FAMILIAR_TRANSPORT)
|
|
uv run familiar serve # explicit alias for the above (used as the Docker CMD)
|
|
uv run familiar login # browser EVE SSO login (adds a character; run once per character)
|
|
uv run familiar status # list logged-in characters
|
|
uv run familiar writes disable # block write actions (default: enabled)
|
|
```
|
|
|
|
There is no separate build step; `uv build` produces the wheel (CI does this on merge).
|
|
|
|
## Architecture
|
|
|
|
**Server assembly.** [src/familiar/server.py](src/familiar/server.py) creates the single `FastMCP` instance and calls `register(mcp)` on each tool module. Every tool module (market, universe, account, industry, trade, structures, killmails, profile, corp, corp_economy, misc, comms, contacts, fittings, fleet, ui, planets, writes) follows the same shape: **module-level async functions** + a `TOOLS` list + `register(mcp)` that loops `mcp.tool(fn)`. Tools are plain importable functions (so tests call them directly); registration is separate. To add tools: new module → import + register in server.py.
|
|
|
|
**ESI access.** [esi.py](src/familiar/esi.py) is a thin async httpx wrapper (`client()` singleton, `get/post/put/delete`, `get_all_pages` for `X-Pages` pagination, Bearer `token=` for authed calls, uniform `EsiError`). Base URL and datasource are env-overridable. [zkill.py](src/familiar/zkill.py) mirrors it for zKillboard. Both expose `set_client()` so tests inject an `httpx.MockTransport`.
|
|
|
|
**Auth is multi-character.** [auth.py](src/familiar/auth.py) does EVE SSO PKCE (no client secret), stores a **dict of characters keyed by id** in one Fernet-encrypted file (`load_store`/`_save_character`/`list_characters`/`remove_character`), and `get_auth(character_id)` returns a valid access token, refreshing via the rotating refresh token when near expiry. The legacy single-character file is migrated on load. Config (client id, callback `localhost:8765`, the 73 read+write scopes split into `READ_SCOPES`/`WRITE_SCOPES`) lives in [config.py](src/familiar/config.py), all env-overridable.
|
|
|
|
**Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these.
|
|
|
|
**Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **on by default** (opt out via `familiar writes disable`; toggle via `familiar writes enable`/`disable` or `FAMILIAR_ENABLE_WRITES`). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly.
|
|
|
|
**Transport selection — the serving choke point.** [serve.py](src/familiar/serve.py) `run()` reads `config.http_settings()` (all `FAMILIAR_*` env, no I/O) and dispatches: `stdio` (default → `mcp.run()`) or `http` (streamable HTTP via `mcp.run(transport="http", ...)`, for Docker behind a TLS reverse proxy). It **fails closed** — a non-loopback bind without `FAMILIAR_HTTP_AUTH_TOKEN` (and without the `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch) raises `InsecureBind`. HTTP auth is a FastMCP `StaticTokenVerifier` bearer token attached in `server.build_http_auth` at construction (ignored by stdio). `server.py` also adds an unauthenticated `/healthz` route for container/proxy probes. The image + `docker-compose.yml` (hardened: non-root, read-only rootfs, `cap_drop: ALL`) mount the encrypted token store at `/data` (`FAMILIAR_DATA_DIR`) and default `FAMILIAR_ENABLE_WRITES=false`; login still happens on a host with a browser. See [docs/deployment.md](docs/deployment.md).
|
|
|
|
## Testing conventions
|
|
|
|
Tests use [tests/conftest.py](tests/conftest.py): the `mock_esi` fixture installs a mock ESI client driven by a request handler, and `route(req, {substring: body})` dispatches by path substring. Async tools are driven with `asyncio.run(...)` (no async-test plugin). Authenticated-tool tests monkeypatch `auth.list_characters` / `auth.get_auth` / `auth.resolve_character` rather than touching the real token store; write tests set `FAMILIAR_DATA_DIR` to a tmp dir and `FAMILIAR_ENABLE_WRITES`. `make_jwt(claims)` builds fake access tokens.
|
|
|
|
## Git / CI workflow
|
|
|
|
- Work happens on `dev`. Feature branches → PR → fast-forward merge into `dev` → delete branch. Cut a release by bumping the version in `pyproject.toml` / `src/familiar/__init__.py` and opening a `dev → main` fast-forward PR (publishes the clean version).
|
|
- `main` and `dev` are protected: no direct pushes, **signed commits required** (the user's git auto-signs), merge via PR.
|
|
- CI (`.gitea/workflows/ci.yml`) runs lint + test on every PR/push; on a merge to main/dev it builds and publishes to the Gitea PyPI registry (auth via the `REGISTRY_TOKEN` secret; twine must NOT use `--skip-existing`). dev publishes a unique `.devN` pre-release.
|
|
- **Do NOT attribute commits, merges, or PRs to Claude / Claude Code.** No `Co-Authored-By: Claude ...` trailer on commits and no "Generated with Claude Code" footer in PR descriptions. Keep messages plain and authored by the user.
|