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.
6.7 KiB
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 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, 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
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 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 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 mirrors it for zKillboard. Both expose set_client() so tests inject an httpx.MockTransport.
Auth is multi-character. 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, all env-overridable.
Per-character / per-corp pattern. 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 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 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.
Testing conventions
Tests use 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 intodev→ delete branch. Cut a release by bumping the version inpyproject.toml/src/familiar/__init__.pyand opening adev → mainfast-forward PR (publishes the clean version). mainanddevare 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 theREGISTRY_TOKENsecret; twine must NOT use--skip-existing). dev publishes a unique.devNpre-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.