Merge pull request 'feat: serve familiar as a remote HTTP MCP server (bearer auth)' (#63) from feat/remote-http into dev
CI / lint (push) Successful in 50s
CI / test (push) Successful in 58s
CI / publish (push) Successful in 51s

Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
Latte committed 2026-07-04 16:46:13 +00:00
commit 3b2c04cde3
12 files changed
+532 -11

No files matched your search

+6 -2
View File
@@ -17,9 +17,11 @@ uv run pytest tests/test_market.py::test_get_market_price # single test
uv run ruff format . # format uv run ruff format . # format
uv run ruff check . # lint (run both before committing; CI enforces them) 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 # 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 # 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 status # list logged-in characters
uv run familiar writes disable # block write actions (default: enabled) 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). There is no separate build step; `uv build` produces the wheel (CI does this on merge).
@@ -28,13 +30,15 @@ There is no separate build step; `uv build` produces the wheel (CI does this on
**Server assembly.** [src/familiar/server.py](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. **Server assembly.** [src/familiar/server.py](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 (e.g. a Mistral custom connector). `configure_http()` attaches a FastMCP `StaticTokenVerifier` from `FAMILIAR_HTTP_TOKEN` (bad/missing bearer → 401 + `WWW-Authenticate: Bearer`) and **refuses to start (`SystemExit`) if the token is unset**. This transport-level bearer token is separate from the EVE-SSO/ESI auth in auth.py. In HTTP mode `configure_http` also 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](src/familiar/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](src/familiar/zkill.py) mirrors it for zKillboard. Both expose `set_client()` so tests inject an `httpx.MockTransport`. **ESI access.** [esi.py](src/familiar/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](src/familiar/zkill.py) mirrors it for zKillboard. Both expose `set_client()` so tests inject an `httpx.MockTransport`.
**Auth is multi-character.** [auth.py](src/familiar/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](src/familiar/config.py), all env-overridable. **Auth is multi-character.** [auth.py](src/familiar/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](src/familiar/config.py), all env-overridable.
**Per-character / per-corp pattern.** [charutil.py](src/familiar/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. **Per-character / per-corp pattern.** [charutil.py](src/familiar/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](src/familiar/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. **Write safety — the choke point.** [writes.py](src/familiar/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 ## Testing conventions
+56
View File
@@ -77,6 +77,62 @@ To opt out and block all writes globally:
uv run familiar writes disable # turn writes off (uv run familiar writes enable to re-allow) uv run familiar writes disable # turn writes off (uv run familiar writes enable to re-allow)
``` ```
## Serve over HTTPS (remote MCP)
By default familiar talks over **stdio** — the local transport Claude launches. To reach it from a remote MCP client (for example a [chat.mistral.ai](https://chat.mistral.ai) custom connector), run the opt-in **streamable-HTTP** transport instead:
```bash
FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve
```
`familiar serve` binds `127.0.0.1:8080/mcp` and expects a TLS-terminating reverse proxy in front of it. It **refuses to start without `FAMILIAR_HTTP_TOKEN`** — an internet-reachable account window must never run unauthenticated. Requests without a valid `Authorization: Bearer <token>` get `401` with a `WWW-Authenticate: Bearer` header; a valid token lets the MCP handshake through. stdio (`familiar` with no args) is unchanged and never token-gated.
Environment variables:
| Variable | Default | Meaning |
| --- | --- | --- |
| `FAMILIAR_HTTP_TOKEN` | *(required)* | Bearer token the client must send. No default — serve refuses to start without it. |
| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address. Leave loopback and let the proxy face the internet. |
| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. |
| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. |
| `FAMILIAR_ENABLE_WRITES` | *(unset)* | In HTTP mode **writes default off**; set to `1`/`true` to opt in. |
**Writes are disabled by default over HTTP** — a network-reachable, LLM-driven endpoint should not send mail or set waypoints unless you explicitly opt in with `FAMILIAR_ENABLE_WRITES=1`. (Over stdio they stay on by default, as before.)
### nginx
Streamable HTTP keeps a long-lived response open, so disable proxy buffering and give it a generous read timeout:
```nginx
location /mcp {
proxy_pass http://127.0.0.1:8080/mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # stream events straight through
proxy_read_timeout 3600s; # don't cut long-lived MCP sessions
}
```
### Add characters on a headless server
`familiar login` opens a browser and captures the callback on localhost, which needs a desktop. On a headless box you have two options:
1. **Headless login** — print the SSO URL, authorize in any browser, paste the redirected URL back:
```bash
uv run familiar login --no-browser
```
The localhost page the browser lands on will fail to load — that's expected; copy its address bar (it contains the `code`) and paste it at the prompt.
2. **Copy the store** — run `familiar login` on your desktop, then copy the encrypted store (`token.enc` + `key` from your `FAMILIAR_DATA_DIR`) to the server's data dir.
### Connect it in Mistral
In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**, set the URL to `https://your-host/mcp`, choose **Bearer Token** auth, and paste the `FAMILIAR_HTTP_TOKEN` value.
## Roadmap ## Roadmap
Built in phases. Each phase is independently useful, so the project stays shippable. Built in phases. Each phase is independently useful, so the project stays shippable.
+28 -1
View File
@@ -40,6 +40,31 @@ Adding tools is mechanical: **write a new module, import it in `server.py`, and
> text predates the write actions and is historical framing; the real behaviour is read **and** > text predates the write actions and is historical framing; the real behaviour is read **and**
> guarded write (see [Write Safety](write-safety.md)). > 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 ## The tool-module pattern
Every tool module — market, universe, account, industry, trade, structures, killmails, profile, corp, Every tool module — market, universe, account, industry, trade, structures, killmails, profile, corp,
@@ -91,7 +116,9 @@ when to call it, so docstrings are written for that audience.
**multi-character** encrypted store. Full details in [Authentication](authentication.md). The **multi-character** encrypted store. Full details in [Authentication](authentication.md). The
short version: short version:
- `interactive_login()` runs the browser flow once per character and persists a bundle. - `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 - The store is a dict of characters keyed by id, in one Fernet-encrypted file; a legacy
single-character file is migrated on load. single-character file is migrated on load.
- `get_auth(character_id)` returns a valid access token, transparently refreshing via the rotating - `get_auth(character_id)` returns a valid access token, transparently refreshing via the rotating
+66 -1
View File
@@ -99,7 +99,72 @@ uv run familiar writes # show current state
Full details in [Write Safety](write-safety.md). Full details in [Write Safety](write-safety.md).
## 7. Verify everything works ## 7. Serve over HTTPS (remote MCP)
Everything above uses **stdio** — the local transport an MCP client launches on your machine.
To reach familiar from a **remote** MCP client (for example a [chat.mistral.ai](https://chat.mistral.ai)
custom connector), run the opt-in **streamable-HTTP** transport instead. stdio stays the default;
nothing here changes how `uv run familiar` behaves with no arguments.
```bash
FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve
```
`familiar serve` binds `127.0.0.1:8080/mcp` behind a TLS-terminating reverse proxy. It **refuses to
start without `FAMILIAR_HTTP_TOKEN`** — an internet-reachable account window must never run
unauthenticated. A request without a valid `Authorization: Bearer <token>` gets `401` with a
`WWW-Authenticate: Bearer` header; a valid token lets the MCP handshake through.
| Variable | Default | Meaning |
| --- | --- | --- |
| `FAMILIAR_HTTP_TOKEN` | *(required)* | Bearer token the client must send; serve refuses to start without it. |
| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address. Leave loopback; let the proxy face the internet. |
| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. |
| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. |
| `FAMILIAR_ENABLE_WRITES` | *(unset)* | In HTTP mode writes default **off**; set to `1`/`true` to opt in. |
> **Writes default off over HTTP.** A network-reachable, LLM-driven endpoint should not send mail or
> set waypoints unless you opt in explicitly with `FAMILIAR_ENABLE_WRITES=1`. (Over stdio they stay on
> by default, as in section 6.)
### nginx location block
Streamable HTTP holds a long-lived response open, so turn off buffering and raise the read timeout:
```nginx
location /mcp {
proxy_pass http://127.0.0.1:8080/mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # stream events straight through
proxy_read_timeout 3600s; # don't cut long-lived MCP sessions
}
```
### Logging in on a headless server
`familiar login` needs a local browser and the `localhost:8765` callback. On a headless box:
- **Headless login** — print the SSO URL, authorize in any browser, paste the redirected URL back:
```bash
uv run familiar login --no-browser
```
The `localhost` page the browser lands on fails to load — that's expected; copy its address bar
(it holds the `code`) and paste it at the prompt.
- **Copy the store** — run `familiar login` on your desktop, then copy the encrypted store
(`token.enc` + `key` from your `FAMILIAR_DATA_DIR`) to the server's data dir.
### Add it in Mistral
In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**,
set the URL to `https://your-host/mcp`, choose **Bearer Token** auth, and paste your
`FAMILIAR_HTTP_TOKEN`.
## 8. Verify everything works
```bash ```bash
uv run pytest # full test suite (fails under 80% coverage) uv run pytest # full test suite (fails under 80% coverage)
+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastmcp>=2.3.0", "fastmcp>=3.0.0", # streamable-HTTP transport + StaticTokenVerifier bearer auth
"httpx>=0.27.0", "httpx>=0.27.0",
"cryptography>=42.0.0", "cryptography>=42.0.0",
] ]
+14 -1
View File
@@ -4,7 +4,10 @@ With no arguments it runs the MCP server over stdio (how Claude Desktop /
Claude Code / Cowork launch it). The auth subcommands are meant to be run by Claude Code / Cowork launch it). The auth subcommands are meant to be run by
hand in a terminal: hand in a terminal:
familiar # run the MCP server over stdio (default)
familiar serve # run the streamable-HTTP transport (remote MCP)
familiar login # add a character via EVE SSO (run once per character) familiar login # add a character via EVE SSO (run once per character)
familiar login --no-browser # headless login: paste the callback URL on stdin
familiar status # list logged-in characters familiar status # list logged-in characters
familiar logout # forget ALL characters familiar logout # forget ALL characters
familiar logout NAME # forget one character (by name or id) familiar logout NAME # forget one character (by name or id)
@@ -35,13 +38,22 @@ def _human_size(num: int) -> str:
def _login() -> int: def _login() -> int:
bundle = auth.interactive_login() no_browser = any(a in {"--no-browser", "--headless"} for a in sys.argv[2:])
bundle = auth.manual_login() if no_browser else auth.interactive_login()
print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).") print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).")
chars = auth.list_characters() chars = auth.list_characters()
print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}") print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}")
return 0 return 0
def _serve() -> int:
"""Run the streamable-HTTP transport (remote MCP). Refuses without a token."""
from .server import serve_http
serve_http()
return 0
def _status() -> int: def _status() -> int:
chars = auth.list_characters() chars = auth.list_characters()
if not chars: if not chars:
@@ -101,6 +113,7 @@ def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else None command = sys.argv[1] if len(sys.argv) > 1 else None
handlers = { handlers = {
"login": _login, "login": _login,
"serve": _serve,
"status": _status, "status": _status,
"logout": _logout, "logout": _logout,
"writes": _writes, "writes": _writes,
+47
View File
@@ -16,6 +16,7 @@ import os
import secrets import secrets
import time import time
import webbrowser import webbrowser
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse from urllib.parse import parse_qs, urlencode, urlparse
@@ -328,6 +329,52 @@ def interactive_login(*, open_browser: bool = True) -> dict[str, Any]:
return bundle return bundle
def parse_callback(raw: str) -> tuple[str, str | None]:
"""Pull ``(code, state)`` out of a pasted callback URL, query, or bare code.
Accepts the full redirect URL (``http://localhost:8765/callback?code=..&state=..``),
just the query string, or the bare authorization code. Returns the code and
the state if one was present (``None`` otherwise). Raises if no code is found.
"""
raw = raw.strip()
if "code=" in raw:
query = urlparse(raw).query or raw
qs = parse_qs(query)
code = qs.get("code", [None])[0]
state = qs.get("state", [None])[0]
else:
code, state = (raw or None), None
if not code:
raise AuthError("No authorization code found in the pasted callback.")
return code, state
def manual_login(*, read_input: Callable[[str], str] = input) -> dict[str, Any]:
"""Headless PKCE login: print the authorize URL, read the callback from stdin.
For servers with no local browser. Prints the EVE SSO URL (open it anywhere),
then reads the pasted callback URL — or a bare code — from ``read_input``.
The browser's redirect to the (unreachable) localhost callback fails
harmlessly; the code is in its address bar. No local listener is started.
"""
verifier, challenge = generate_pkce()
state = secrets.token_urlsafe(16)
url = build_authorize_url(state, challenge)
print(
"Open this URL in any browser, authorize, then copy the address bar of "
"the page it redirects to (the localhost page will fail to load — that's "
"expected):\n\n"
f"{url}\n"
)
code, got_state = parse_callback(read_input("Paste the callback URL (or code): "))
if got_state is not None and got_state != state:
raise AuthError("State mismatch — possible CSRF; aborting login.")
resp = asyncio.run(exchange_code(code, verifier))
bundle = _bundle_from_response(resp, now=time.time())
_save_character(bundle)
return bundle
def status() -> dict[str, Any]: def status() -> dict[str, Any]:
"""Report which characters are logged in (no network call).""" """Report which characters are logged in (no network call)."""
chars = list_characters() chars = list_characters()
+21
View File
@@ -111,6 +111,27 @@ WRITE_SCOPES = [
SCOPES = os.environ.get("FAMILIAR_SCOPES", " ".join(READ_SCOPES + WRITE_SCOPES)).split() SCOPES = os.environ.get("FAMILIAR_SCOPES", " ".join(READ_SCOPES + WRITE_SCOPES)).split()
# --------------------------------------------------------------------------- #
# HTTP (remote MCP) transport — opt-in via `familiar serve`.
# --------------------------------------------------------------------------- #
# stdio stays the default (how Claude launches familiar locally). `familiar
# serve` runs the streamable-HTTP transport instead, meant to sit behind a TLS
# reverse proxy which terminates HTTPS — so the default bind is loopback. Every
# value is env-overridable; the token has no default on purpose (see
# ``http_token`` — HTTP mode refuses to start without one).
HTTP_HOST = os.environ.get("FAMILIAR_HTTP_HOST", "127.0.0.1")
HTTP_PORT = int(os.environ.get("FAMILIAR_HTTP_PORT", "8080"))
HTTP_PATH = os.environ.get("FAMILIAR_HTTP_PATH", "/mcp")
def http_token() -> str:
"""The expected bearer token for the HTTP transport (empty if unset).
Read live from ``FAMILIAR_HTTP_TOKEN`` so `familiar serve` can refuse to
start when it is missing rather than exposing an unauthenticated server.
"""
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
def data_dir() -> Path: def data_dir() -> Path:
"""Where the encrypted token and key live. Created with restricted perms.""" """Where the encrypted token and key live. Created with restricted perms."""
+54
View File
@@ -15,6 +15,7 @@ from . import (
__version__, __version__,
account, account,
comms, comms,
config,
contacts, contacts,
corp, corp,
corp_economy, corp_economy,
@@ -107,3 +108,56 @@ ui.register(mcp)
# Phase 4: planetary interaction (read-only). # Phase 4: planetary interaction (read-only).
planets.register(mcp) planets.register(mcp)
# --------------------------------------------------------------------------- #
# Remote (HTTP) transport — opt-in via `familiar serve`. stdio stays default.
# --------------------------------------------------------------------------- #
def _bearer_verifier(token: str):
"""A FastMCP verifier that accepts exactly one bearer token.
Uses FastMCP's built-in StaticTokenVerifier: a request with a matching
``Authorization: Bearer <token>`` passes; anything else gets a 401 with a
``WWW-Authenticate: Bearer`` header. Imported lazily so the stdio path never
pulls in the auth stack.
"""
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
def configure_http(token: str | None = None) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving and return the token.
Attaches bearer auth and switches writes to their network default (disabled
unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit``
if no token is configured — an internet-reachable server must never run open.
"""
token = token if token is not None else config.http_token()
if not token:
raise SystemExit(
"Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. "
"Set it to a long random secret (the bearer token your MCP client "
"sends) and try again."
)
writes.set_http_context(True)
mcp.auth = _bearer_verifier(token)
return token
def serve_http(
*, host: str | None = None, port: int | None = None, path: str | None = None
) -> None:
"""Run familiar over the streamable-HTTP transport with bearer auth.
Binds loopback by default (a TLS reverse proxy is expected to terminate
HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
config. Refuses to start without FAMILIAR_HTTP_TOKEN.
"""
configure_http()
mcp.run(
transport="http",
host=host or config.HTTP_HOST,
port=port or config.HTTP_PORT,
path=path or config.HTTP_PATH,
)
+22 -4
View File
@@ -28,15 +28,33 @@ def _flag_file():
return config.data_dir() / "writes_disabled" return config.data_dir() / "writes_disabled"
def writes_enabled() -> bool: # Flipped on by `familiar serve` (HTTP transport). It only changes what the
"""True if writes are allowed (env override or the on-disk toggle). # *default* is when FAMILIAR_ENABLE_WRITES is unset: a network-reachable,
# LLM-driven endpoint should not send mail or set waypoints unless the operator
# opted in explicitly, so in HTTP mode the default is disabled rather than
# enabled. An explicit env value (either way) still wins.
_http_context = False
Writes are **enabled by default**; the user opts out by creating the
disable-flag file (`familiar writes disable`) or setting the env var. def set_http_context(enabled: bool) -> None:
"""Mark the process as serving over HTTP so writes default to disabled."""
global _http_context
_http_context = enabled
def writes_enabled() -> bool:
"""True if writes are allowed (env override, HTTP default, or on-disk toggle).
Precedence: an explicit ``FAMILIAR_ENABLE_WRITES`` wins. With it unset,
writes are **disabled by default in HTTP mode** (see :func:`set_http_context`)
and **enabled by default** otherwise (the local stdio case), unless the user
opted out with the disable-flag file (`familiar writes disable`).
""" """
env = os.environ.get("FAMILIAR_ENABLE_WRITES") env = os.environ.get("FAMILIAR_ENABLE_WRITES")
if env is not None: if env is not None:
return env.strip().lower() in {"1", "true", "yes", "on"} return env.strip().lower() in {"1", "true", "yes", "on"}
if _http_context:
return False
return not _flag_file().exists() return not _flag_file().exists()
+216
View File
@@ -0,0 +1,216 @@
"""Tests for the opt-in HTTP transport, bearer auth, and headless login.
The stdio path and every existing tool stay untouched; these only exercise the
new `familiar serve` surface: the bearer verifier (401 without / with a bad
token, through with a good one), the start-refusal when no token is set, the
FAMILIAR_HTTP_* config defaults, the write-default flip in HTTP mode, and the
headless-login callback parsing.
"""
from __future__ import annotations
import asyncio
import importlib
import httpx
import pytest
from conftest import make_jwt
from familiar import auth, config, server, writes
# --------------------------------------------------------------------------- #
# Bearer auth on the HTTP transport
# --------------------------------------------------------------------------- #
def _initialize_request() -> dict:
return {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
}
async def _status_for(app, authorization: str | None) -> int:
"""POST an MCP initialize to the ASGI app and return the HTTP status."""
headers = {"Accept": "application/json, text/event-stream"}
if authorization is not None:
headers["Authorization"] = authorization
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/mcp", json=_initialize_request(), headers=headers)
return resp.status_code, resp.headers.get("www-authenticate")
@pytest.fixture
def http_server(monkeypatch):
"""Configure the shared mcp for HTTP with a known token, then reset it."""
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", "s3cret-token")
original_auth = server.mcp.auth
token = server.configure_http()
app = server.mcp.http_app(path="/mcp")
yield token, app
server.mcp.auth = original_auth
writes.set_http_context(False)
def test_missing_token_is_401_with_www_authenticate(http_server):
_, app = http_server
status, www_auth = asyncio.run(_status_for(app, None))
assert status == 401
assert www_auth and www_auth.lower().startswith("bearer")
def test_wrong_token_is_401(http_server):
_, app = http_server
status, _ = asyncio.run(_status_for(app, "Bearer nope"))
assert status == 401
def test_valid_token_passes_handshake(http_server):
token, app = http_server
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 200
# --------------------------------------------------------------------------- #
# Start refusal without a token
# --------------------------------------------------------------------------- #
def test_configure_http_refuses_without_token(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
original_auth = server.mcp.auth
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http()
assert "FAMILIAR_HTTP_TOKEN" in str(excinfo.value)
finally:
server.mcp.auth = original_auth
writes.set_http_context(False)
def test_configure_http_refuses_on_blank_token(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", " ")
original_auth = server.mcp.auth
try:
with pytest.raises(SystemExit):
server.configure_http()
finally:
server.mcp.auth = original_auth
writes.set_http_context(False)
# --------------------------------------------------------------------------- #
# Config defaults for the new env vars
# --------------------------------------------------------------------------- #
def test_http_config_defaults(monkeypatch):
for var in ("FAMILIAR_HTTP_HOST", "FAMILIAR_HTTP_PORT", "FAMILIAR_HTTP_PATH"):
monkeypatch.delenv(var, raising=False)
try:
importlib.reload(config)
assert config.HTTP_HOST == "127.0.0.1"
assert config.HTTP_PORT == 8080
assert config.HTTP_PATH == "/mcp"
finally:
importlib.reload(config)
def test_http_config_overrides(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_HOST", "0.0.0.0")
monkeypatch.setenv("FAMILIAR_HTTP_PORT", "9001")
monkeypatch.setenv("FAMILIAR_HTTP_PATH", "/eve")
try:
importlib.reload(config)
assert config.HTTP_HOST == "0.0.0.0"
assert config.HTTP_PORT == 9001
assert config.HTTP_PATH == "/eve"
finally:
for var in ("FAMILIAR_HTTP_HOST", "FAMILIAR_HTTP_PORT", "FAMILIAR_HTTP_PATH"):
monkeypatch.delenv(var, raising=False)
importlib.reload(config)
def test_http_token_reads_and_strips_env(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", " padded ")
assert config.http_token() == "padded"
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
assert config.http_token() == ""
# --------------------------------------------------------------------------- #
# Write default in the HTTP context
# --------------------------------------------------------------------------- #
def test_http_context_disables_writes_by_default(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False)
try:
writes.set_http_context(True)
assert writes.writes_enabled() is False
finally:
writes.set_http_context(False)
def test_http_context_write_env_still_wins(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "1")
try:
writes.set_http_context(True)
assert writes.writes_enabled() is True
finally:
writes.set_http_context(False)
def test_stdio_writes_stay_enabled_by_default(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False)
writes.set_http_context(False)
assert writes.writes_enabled() is True
# --------------------------------------------------------------------------- #
# Headless login callback parsing + flow
# --------------------------------------------------------------------------- #
def test_parse_callback_full_url():
code, state = auth.parse_callback("http://localhost:8765/callback?code=abc123&state=xyz")
assert code == "abc123"
assert state == "xyz"
def test_parse_callback_bare_code():
code, state = auth.parse_callback(" justthecode ")
assert code == "justthecode"
assert state is None
def test_parse_callback_rejects_empty():
with pytest.raises(auth.AuthError):
auth.parse_callback("")
def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
saved: dict = {}
async def fake_exchange(code, verifier, **kwargs):
assert code == "the-code"
return {
"access_token": make_jwt({"sub": "CHARACTER:EVE:42", "name": "Pilot", "scp": []}),
"refresh_token": "refresh-xyz",
"expires_in": 1200,
}
monkeypatch.setattr(auth, "exchange_code", fake_exchange)
monkeypatch.setattr(auth, "_save_character", lambda bundle: saved.update(bundle))
bundle = auth.manual_login(
read_input=lambda _prompt: "http://localhost:8765/callback?code=the-code"
)
assert bundle["character_id"] == 42
assert bundle["character_name"] == "Pilot"
assert saved["character_id"] == 42
Generated
+1 -1
View File
@@ -439,7 +439,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cryptography", specifier = ">=42.0.0" }, { name = "cryptography", specifier = ">=42.0.0" },
{ name = "fastmcp", specifier = ">=2.3.0" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
] ]