Files
familiar/doc/architecture.md
T
2026-06-27 21:30:08 +02:00

9.3 KiB

Architecture

familiar is a small, layered codebase. Every authenticated tool ultimately rests on four shared pieces — the server assembly, the ESI client, the auth/token store, and the per-character helpers — with a single write-safety choke point on top. This document explains each layer and the conventions that keep ~80 tools consistent.

              ┌──────────────────────────────────────────────────┐
   MCP client │  Claude Code / Claude Desktop / Cowork (stdio)    │
              └───────────────────────┬──────────────────────────┘
                                      │  MCP over stdio
              ┌───────────────────────▼──────────────────────────┐
              │  server.py — one FastMCP instance, ping + register │
              └───────────────────────┬──────────────────────────┘
        register(mcp) on each tool module (market, universe, account, …)
              ┌───────────────────────┼──────────────────────────┐
              │                       │                          │
     ┌────────▼────────┐    ┌─────────▼─────────┐    ┌───────────▼──────────┐
     │  charutil.py    │    │   writes.py       │    │  lookup.py / sde.py   │
     │ run_per_*       │    │  guard() gate     │    │  name → id resolution │
     └────────┬────────┘    └─────────┬─────────┘    └───────────┬──────────┘
              │                       │                          │
     ┌────────▼────────┐    ┌─────────▼─────────┐                │
     │   auth.py       │    │   esi.py / zkill  │◄───────────────┘
     │ EVE SSO + store │    │  async HTTP wrap  │
     └─────────────────┘    └───────────────────┘

Server assembly — server.py

server.py creates the single FastMCP instance and calls register(mcp) on each tool module in turn. It also defines the one built-in tool, ping, a health check that returns the server name, version and UTC time.

Adding tools is mechanical: write a new module, import it in server.py, and call its register(mcp). Nothing else wires it up.

Note: the instructions= string passed to FastMCP still describes familiar as "read-only". That text predates the write actions and is historical framing; the real behaviour is read and guarded write (see Write Safety).

The tool-module pattern

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:

# 1. module-level async functions = the tools (plain, importable, testable)
async def get_something(character: str | int | None = None) -> dict[str, Any]:
    """One-line docstring becomes the tool description the AI sees."""
    ...

# 2. a TOOLS list naming the functions to expose
TOOLS = [get_something, ...]

# 3. register() loops mcp.tool(fn)
def register(mcp: FastMCP) -> None:
    for fn in TOOLS:
        mcp.tool(fn)

The key idea: tools are plain functions; registration is separate. Tests import and call the functions directly without any MCP machinery. The function's docstring is what the AI reads to decide when to call it, so docstrings are written for that audience.

ESI access — esi.py

esi.py is a thin async wrapper around httpx for the EVE Swagger Interface:

  • A shared singleton EsiClient via client(); set_client() lets tests inject an httpx.MockTransport.
  • get / post / put / delete, plus get_all_pages which follows the X-Pages header to concatenate every page of a paginated endpoint.
  • Bearer auth via a token= argument for authenticated calls; public calls omit it.
  • A uniform EsiError carrying the HTTP status.
  • A descriptive User-Agent (ESI asks for one).
  • Response caching honoring the ESI Expires header — GET responses are cached until they expire (disable with FAMILIAR_DISABLE_CACHE).
  • Error-limit back-off — ESI publishes a rolling error budget in response headers; when it nears exhaustion (FAMILIAR_ERROR_LIMIT_THRESHOLD, default 5) the client waits for the window to reset before issuing more requests.

zkill.py mirrors this design for the public zKillboard API, used by the killmail tools.

Authentication — auth.py

auth.py implements EVE SSO with PKCE (no client secret) and a multi-character encrypted store. Full details in Authentication. The short version:

  • interactive_login() runs the browser flow once per character and persists a bundle.
  • The store is a dict of characters keyed by id, in one Fernet-encrypted file; a legacy single-character file is migrated on load.
  • get_auth(character_id) returns a valid access token, transparently refreshing via the rotating refresh token when the current one is within 60s of expiry.
  • list_characters / resolve_character / remove_character manage the store; status() reports it without any network call.

Config (client id, callback host/port, scopes, data dir) lives in config.py, all environment-overridable — see Configuration.

Per-character / per-corp pattern — charutil.py

charutil.py is the backbone of every authenticated tool. Authenticated read tools take an optional character argument and run over one named character or all logged-in ones:

  • run_per_character(character, fetch) — resolves the target(s), gets a refreshed token for each, runs fetch(token, character_id), and returns a uniform {"characters": [...]} list. Each result is tagged with character_id and character_name.
  • run_per_corp(character, fetch) — same, but also resolves each character's corporation_id first (corp endpoints 403 without the right corp role; that surfaces as an EsiError for the affected character). fetch receives (token, corporation_id, character_id).
  • one_token(character) — picks a single (token, character_id) for lookups about a thing (e.g. a structure) rather than per character.
  • character_targets(character) — (id, name) pairs without needing a token, for public lookups keyed by character id (e.g. zKillboard).
  • summarize(items, limit) — the uniform {count, sample} shape used to cap large lists.

Because of this layer, a typical read tool is just a few lines: resolve names to ids, call ESI inside a small fetch, hand it to run_per_character.

Name resolution — lookup.py, hubs.py, sde.py

  • lookup.py resolves names to ids via ESI's /universe/ids/ endpoint, so a user can say "Tritanium" or "Jita" instead of a numeric id. resolve_id short-circuits if the value is already numeric and prefers the offline SDE for inventory types when one is present.
  • hubs.py holds the static table of NPC trade hubs (Jita, Amarr, Dodixie, Rens, Hek) with their region/system/station ids, used by the market tools.
  • sde.py is an optional local Static Data Export store. When a prepared SQLite copy is on disk (FAMILIAR_SDE_PATH or <data_dir>/sde.sqlite), familiar resolves type names and serves item details offline. It degrades gracefully: if the file or a needed table is absent, available() reports False and callers fall back to ESI. sde_status is the diagnostic tool reporting this.

Write safety — writes.py

writes.py provides guard(action, params, confirm, execute), the single gate every mutating tool calls. It is the only place a write to ESI is permitted. See Write Safety for the full semantics. The rule for contributors: never call a mutating ESI endpoint directly — always route through writes.guard.

Conventions summary

  • Reads take an optional character (omit → all logged-in characters); writes take a required character plus a confirm flag.
  • Authenticated tools build on run_per_character / run_per_corp; thing-scoped ones use one_token.
  • Large lists are capped via summarize(items, limit).
  • Docstrings are the AI-facing tool descriptions — keep them one clear sentence.
  • Tests call tool functions directly and inject mock transports via set_client / set_connection.