- 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.
182 lines
11 KiB
Markdown
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`.
|