Files
Latte df9e06a3e7
CI / lint (pull_request) Successful in 55s
docker / lint (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m6s
docker / test (pull_request) Successful in 1m4s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Failing after 1m11s
docs: deployment guide + per-client setup + connect matrix
- doc/deployment.md: run familiar as a remote MCP server — secure defaults,
  bearer vs OAuth modes, the full env-var reference, one-time login / token-store
  provisioning, the published images, docker compose, and TLS reverse-proxy
  blocks for nginx (proxy_buffering off + long read timeout), Caddy, and Traefik.
- doc/clients/: one page per client with exact working steps — Claude Code,
  VS Code (.vscode/mcp.json with a masked token input, and the workspace
  .mcp.json header gotcha), Mistral Le Chat, Claude Desktop + Cowork (mcp-remote
  bridge, per-OS config paths), and Claude web (oauth mode + external IdP, with
  "use Desktop + bearer" as the simpler alternative). Plus a clients/ index.
- README: a "Connect a client" matrix (client -> auth -> guide) and a compact
  "Deploy with Docker" section linking to doc/deployment.md; oauth mode noted.
- doc/README.md index: rows for Deployment and Client setup.
- doc/getting-started.md: pointer from the HTTPS quickstart to the full docs.
- CLAUDE.md: FAMILIAR_AUTH_MODE / FAMILIAR_OAUTH_* / FAMILIAR_ALLOW_INSECURE_BIND
  and /healthz in the transport note; the split ci.yml / docker.yml workflows.

Deployment docs live under the existing doc/ tree (the repo uses doc/, not a
separate docs/) to keep a single documentation directory.
2026-07-04 19:26:33 +02:00

8.4 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 over stdio (how Claude launches it)
uv run familiar serve         # run the streamable-HTTP transport (remote MCP; needs FAMILIAR_HTTP_TOKEN)
uv run familiar login         # browser EVE SSO login (adds a character; run once per character)
uv run familiar login --no-browser  # headless login: paste the callback URL on stdin
uv run familiar status        # list logged-in characters
uv run familiar writes disable # block write actions (default: enabled on stdio, disabled on HTTP)

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.

Transport (stdio default, opt-in HTTP). familiar (no args) runs mcp.run() over stdio (how Claude launches it) — local, no auth. familiar serve runs server.serve_http(): the FastMCP streamable-HTTP transport bound to loopback (FAMILIAR_HTTP_HOST/FAMILIAR_HTTP_PORT/FAMILIAR_HTTP_PATH, default 127.0.0.1:8080/mcp) behind a TLS reverse proxy, for remote MCP clients (Mistral, Claude web/desktop/code, VS Code — see doc/clients/). An unauthenticated GET /healthz liveness route (returns only status/server/version) is served for Docker/proxy probes. This transport-level auth is separate from the EVE-SSO/ESI auth in auth.py.

configure_http(host=, token=, verifier=) picks the auth provider from FAMILIAR_AUTH_MODE:

  • bearer (default): a StaticTokenVerifier from FAMILIAR_HTTP_TOKEN (bad/missing bearer → 401 + WWW-Authenticate: Bearer). Fail-closed — no token → SystemExit, unless FAMILIAR_ALLOW_INSECURE_BIND=true explicitly permits a tokenless bind (the message distinguishes loopback vs a public interface via config.is_loopback).
  • oauth: familiar is an OAuth 2.1 resource server only — a JWTVerifier (JWKS from FAMILIAR_OAUTH_JWKS_URI or discovered from FAMILIAR_OAUTH_ISSUER's OIDC metadata, validating issuer + audience) wrapped in a RemoteAuthProvider that serves /.well-known/oauth-protected-resource/<path>. Config: FAMILIAR_OAUTH_ISSUER, FAMILIAR_OAUTH_RESOURCE (this server's public URL, required), FAMILIAR_OAUTH_AUDIENCE (defaults to resource). No DCR/authorization-server in familiar; needs an external IdP (Authentik/Keycloak) for the claude.ai web connector.

Either mode calls writes.set_http_context(True), flipping the write default to disabled unless FAMILIAR_ENABLE_WRITES is explicitly truthy (only the default resolution changes; writes.guard is untouched).

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 over stdio (opt out via familiar writes disable; toggle via familiar writes enable/disable or FAMILIAR_ENABLE_WRITES) but off by default over HTTP (see the transport note — set_http_context flips the default; FAMILIAR_ENABLE_WRITES still overrides). 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.

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 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.
  • Two workflows run lint + test on every PR/push. .gitea/workflows/ci.yml additionally builds + publishes the Python wheel to the Gitea PyPI registry on a merge to main/dev (auth via the REGISTRY_TOKEN secret; twine must NOT use --skip-existing; dev publishes a unique .devN pre-release). .gitea/workflows/docker.yml additionally builds + smoke-tests the Docker image (docker/Dockerfile, runs familiar serve; checks non-root, /healthz 200, MCP 401 without / 200 with a token) on every run and, on a merge to main/dev, pushes it to the Gitea container registry git.hiddenden.cafe/hiddenden/familiar (main → :latest/:main, dev → :dev, plus :sha-<12>) using the same REGISTRY_TOKEN. The two workflows are independent and must not interfere.
  • 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.