Files
Latte ceb907b2fe
CI / lint (pull_request) Successful in 51s
CI / test (pull_request) Successful in 58s
CI / publish (pull_request) Has been skipped
docs: document the remote-HTTP serve mode, env vars, nginx and Mistral setup
- README + getting-started: a "Serve over HTTPS (remote MCP)" section with the
  `familiar serve` command, the FAMILIAR_HTTP_* env vars, the write-off-by-
  default note, an nginx location block (proxy_buffering off, long read
  timeout), the headless-login / copy-the-store options, and the steps to add
  it as a Mistral custom connector.
- architecture: describe the stdio/HTTP transport split, the bearer-auth gate,
  and the HTTP write default.
- CLAUDE.md: add the new commands and env vars to Commands + Architecture.
2026-07-04 18:22:09 +02:00

182 lines
11 KiB
Markdown

# 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`](../src/familiar/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](write-safety.md)).
## Transport — stdio (default) and HTTP (opt-in)
The same `mcp` instance is served over one of two transports; the tools and every layer below are
identical either way.
- **stdio (default).** `familiar` with no arguments calls `mcp.run()` — the local transport Claude
Desktop / Claude Code / Cowork launch over the process's stdin/stdout. No network, no auth layer:
the transport is local to the machine and trusted.
- **streamable-HTTP (opt-in).** `familiar serve` calls `server.serve_http()`, which runs the FastMCP
streamable-HTTP transport bound to loopback (`FAMILIAR_HTTP_HOST`/`PORT`/`PATH`, default
`127.0.0.1:8080/mcp`) behind a TLS-terminating reverse proxy. `configure_http()` attaches a FastMCP
`StaticTokenVerifier` built from `FAMILIAR_HTTP_TOKEN`, so a request must present a matching
`Authorization: Bearer <token>` — anything else is a `401` with a `WWW-Authenticate: Bearer` header.
Serving **refuses to start without a token** (a `SystemExit` from `configure_http`), so the endpoint
is never open to the internet unauthenticated.
The EVE-SSO/ESI auth in `auth.py` is unrelated to this transport-level bearer token: the bearer token
gates the *MCP client* (e.g. Mistral) reaching familiar, while `auth.py` gates familiar reaching ESI.
**Write default depends on the transport.** `configure_http()` calls `writes.set_http_context(True)`,
which flips the *default* write policy: over HTTP writes are **disabled** unless `FAMILIAR_ENABLE_WRITES`
is explicitly truthy, because a network-reachable, LLM-driven endpoint should not send mail or set
waypoints by accident. Over stdio the default is unchanged (writes on). This only changes the default
resolution — the `writes.guard` choke point and the per-call `confirm=true` requirement are untouched.
## 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:
```python
# 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`](../src/familiar/esi.py) is a thin async wrapper around `httpx` for the
[EVE Swagger Interface](https://esi.evetech.net/):
- 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`](../src/familiar/zkill.py) mirrors this design for the public
[zKillboard](https://zkillboard.com/) API, used by the killmail tools.
## Authentication — `auth.py`
[`auth.py`](../src/familiar/auth.py) implements EVE SSO with PKCE (no client secret) and a
**multi-character** encrypted store. Full details in [Authentication](authentication.md). The
short version:
- `interactive_login()` runs the browser flow once per character and persists a bundle;
`manual_login()` is the headless variant (`familiar login --no-browser`) that prints the authorize
URL and reads the pasted callback URL from stdin — no local browser or callback listener needed.
- 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`](../src/familiar/config.py), all environment-overridable — see
[Configuration](configuration.md).
## Per-character / per-corp pattern — `charutil.py`
[`charutil.py`](../src/familiar/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`](../src/familiar/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`](../src/familiar/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`](../src/familiar/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`](../src/familiar/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](write-safety.md) 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`.