From 458e31685f8ff12c6192c25038802b3f9ec95356 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 18:20:59 +0200 Subject: [PATCH 01/10] feat(serve): opt-in HTTP transport with bearer auth + headless login Add a `familiar serve` subcommand that runs the FastMCP streamable-HTTP transport for use as a remote MCP server (e.g. a Mistral custom connector), while stdio stays the default and is completely unchanged. - config: FAMILIAR_HTTP_HOST/PORT/PATH (default 127.0.0.1:8080/mcp) and a live http_token() reader for FAMILIAR_HTTP_TOKEN. - server: configure_http()/serve_http() attach a StaticTokenVerifier so a missing/wrong bearer token gets 401 + WWW-Authenticate: Bearer and a valid one passes the handshake. Refuses to start (SystemExit) without a token. - writes: in HTTP mode the write default flips to disabled unless FAMILIAR_ENABLE_WRITES is explicitly truthy; guard() logic is untouched. - auth: manual_login() + parse_callback() power `login --no-browser`, printing the SSO URL and reading the pasted callback from stdin (no local browser). - bump fastmcp pin to >=3.0.0 for the streamable-HTTP + verifier API. --- pyproject.toml | 2 +- src/familiar/__main__.py | 15 ++++++++++- src/familiar/auth.py | 47 ++++++++++++++++++++++++++++++++++ src/familiar/config.py | 21 ++++++++++++++++ src/familiar/server.py | 54 ++++++++++++++++++++++++++++++++++++++++ src/familiar/writes.py | 26 ++++++++++++++++--- uv.lock | 2 +- 7 files changed, 160 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6b1974..5e34e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" license = { file = "LICENSE" } dependencies = [ - "fastmcp>=2.3.0", + "fastmcp>=3.0.0", # streamable-HTTP transport + StaticTokenVerifier bearer auth "httpx>=0.27.0", "cryptography>=42.0.0", ] diff --git a/src/familiar/__main__.py b/src/familiar/__main__.py index cd3158a..56caf25 100644 --- a/src/familiar/__main__.py +++ b/src/familiar/__main__.py @@ -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 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 --no-browser # headless login: paste the callback URL on stdin familiar status # list logged-in characters familiar logout # forget ALL characters familiar logout NAME # forget one character (by name or id) @@ -35,13 +38,22 @@ def _human_size(num: int) -> str: 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')}).") chars = auth.list_characters() print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}") 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: chars = auth.list_characters() if not chars: @@ -101,6 +113,7 @@ def main() -> None: command = sys.argv[1] if len(sys.argv) > 1 else None handlers = { "login": _login, + "serve": _serve, "status": _status, "logout": _logout, "writes": _writes, diff --git a/src/familiar/auth.py b/src/familiar/auth.py index 4d315e8..ae95cb4 100644 --- a/src/familiar/auth.py +++ b/src/familiar/auth.py @@ -16,6 +16,7 @@ import os import secrets import time import webbrowser +from collections.abc import Callable from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlencode, urlparse @@ -328,6 +329,52 @@ def interactive_login(*, open_browser: bool = True) -> dict[str, Any]: 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]: """Report which characters are logged in (no network call).""" chars = list_characters() diff --git a/src/familiar/config.py b/src/familiar/config.py index 6d02dc1..7858861 100644 --- a/src/familiar/config.py +++ b/src/familiar/config.py @@ -111,6 +111,27 @@ WRITE_SCOPES = [ 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: """Where the encrypted token and key live. Created with restricted perms.""" diff --git a/src/familiar/server.py b/src/familiar/server.py index 18a1b72..8c52747 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -15,6 +15,7 @@ from . import ( __version__, account, comms, + config, contacts, corp, corp_economy, @@ -107,3 +108,56 @@ ui.register(mcp) # Phase 4: planetary interaction (read-only). 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 `` 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, + ) diff --git a/src/familiar/writes.py b/src/familiar/writes.py index 2efada6..74e9dbc 100644 --- a/src/familiar/writes.py +++ b/src/familiar/writes.py @@ -28,15 +28,33 @@ def _flag_file(): return config.data_dir() / "writes_disabled" -def writes_enabled() -> bool: - """True if writes are allowed (env override or the on-disk toggle). +# Flipped on by `familiar serve` (HTTP transport). It only changes what the +# *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") if env is not None: return env.strip().lower() in {"1", "true", "yes", "on"} + if _http_context: + return False return not _flag_file().exists() diff --git a/uv.lock b/uv.lock index ff58b1a..eb94ac5 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=42.0.0" }, - { name = "fastmcp", specifier = ">=2.3.0" }, + { name = "fastmcp", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, ] -- 2.54.0 From 44b2dbac0c32ee1bc7c651c239da533b8096da93 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 18:22:01 +0200 Subject: [PATCH 02/10] test(serve): cover HTTP bearer auth, start refusal, config, write default, headless login - 401 without/with a bad token (incl. WWW-Authenticate: Bearer), 200 with a valid one, driven through the ASGI app in-process. - configure_http refuses to start when the token is unset or blank. - FAMILIAR_HTTP_* config defaults and overrides; http_token() strip/empty. - HTTP write default disabled by default, env still wins, stdio stays enabled. - parse_callback (URL / bare code / empty) and the manual_login exchange flow. --- tests/test_serve.py | 216 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/test_serve.py diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..7acda01 --- /dev/null +++ b/tests/test_serve.py @@ -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 -- 2.54.0 From ceb907b2fe53b8222b06fd2460cda1efe7be5127 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 18:22:09 +0200 Subject: [PATCH 03/10] 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. --- CLAUDE.md | 8 +++-- README.md | 56 +++++++++++++++++++++++++++++++++++ doc/architecture.md | 29 +++++++++++++++++- doc/getting-started.md | 67 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 05be6da..bdf29b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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) +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). @@ -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. +**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`. **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. -**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 diff --git a/README.md b/README.md index e05c00a..a21605e 100644 --- a/README.md +++ b/README.md @@ -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) ``` +## 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 ` 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 Built in phases. Each phase is independently useful, so the project stays shippable. diff --git a/doc/architecture.md b/doc/architecture.md index 6187750..2cd69ab 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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** > 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 ` — 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, @@ -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 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 single-character file is migrated on load. - `get_auth(character_id)` returns a valid access token, transparently refreshing via the rotating diff --git a/doc/getting-started.md b/doc/getting-started.md index 1214b83..1ad5655 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -99,7 +99,72 @@ uv run familiar writes # show current state 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 ` 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 uv run pytest # full test suite (fails under 80% coverage) -- 2.54.0 From 7bb99d94bf4ed72a6f61f59e0d47c23487238557 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:25:52 +0200 Subject: [PATCH 04/10] feat(serve): /healthz, fail-closed bind, and OAuth resource-server mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the HTTP transport (from the remote-MCP work) so it's production-ready. - /healthz: an unauthenticated GET liveness route that returns only {status, server, version} — no account data, no token state. For Docker and reverse-proxy probes; on stdio the route is never served. - Fail-closed bind: refuse to start without authentication, and make the message distinguish a loopback bind from a public (0.0.0.0) one. The only override is the explicit FAMILIAR_ALLOW_INSECURE_BIND=true escape hatch, which permits an unauthenticated bind (with a loud warning). - FAMILIAR_AUTH_MODE=oauth: run as an OAuth 2.1 resource server. A JWTVerifier validates external-issuer JWTs (JWKS from FAMILIAR_OAUTH_JWKS_URI, else discovered from FAMILIAR_OAUTH_ISSUER's OIDC metadata) against issuer + audience, wrapped in a RemoteAuthProvider that serves /.well-known/oauth-protected-resource so OAuth-only clients (Claude web) can discover the authorization server. familiar is a resource server only — no authorization server, no DCR. bearer mode is unchanged and stays the default. Tests cover healthz (200, no leak), fail-closed vs escape hatch, and oauth (valid JWT 200; missing/expired/wrong-audience 401 + WWW-Authenticate; the protected-resource metadata), plus the new config readers. --- src/familiar/config.py | 63 +++++++++++++++ src/familiar/server.py | 119 +++++++++++++++++++++++---- tests/test_serve.py | 177 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 15 deletions(-) diff --git a/src/familiar/config.py b/src/familiar/config.py index 7858861..3b6447f 100644 --- a/src/familiar/config.py +++ b/src/familiar/config.py @@ -133,6 +133,69 @@ def http_token() -> str: return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip() +# Hosts that are *not* reachable from off-box. A bind to anything else is a +# public interface and must be authenticated (or explicitly waived). +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""} + + +def is_loopback(host: str) -> bool: + """True if binding ``host`` only exposes the server to the local machine.""" + return (host or "").strip().lower() in LOOPBACK_HOSTS + + +def _env_truthy(name: str) -> bool: + return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def allow_insecure_bind() -> bool: + """Escape hatch: allow a public (non-loopback) bind without a token. + + Off by default. Only honor ``FAMILIAR_ALLOW_INSECURE_BIND`` when the operator + really means to run unauthenticated (e.g. auth handled entirely upstream). + """ + return _env_truthy("FAMILIAR_ALLOW_INSECURE_BIND") + + +# --------------------------------------------------------------------------- # +# HTTP auth mode: bearer (default) or oauth (OAuth 2.1 resource server). +# --------------------------------------------------------------------------- # +def auth_mode() -> str: + """``bearer`` (default) or ``oauth`` — how the HTTP transport authenticates. + + ``bearer``: a single static token in ``FAMILIAR_HTTP_TOKEN``. + ``oauth``: validate external-issuer JWTs (JWKS) and advertise the + protected-resource metadata so OAuth-only clients (e.g. Claude web) can + discover the authorization server. + """ + mode = (os.environ.get("FAMILIAR_AUTH_MODE") or "bearer").strip().lower() + return mode if mode in {"bearer", "oauth"} else "bearer" + + +def oauth_issuer() -> str: + return (os.environ.get("FAMILIAR_OAUTH_ISSUER") or "").strip() + + +def oauth_audience() -> str: + """Expected token audience. Defaults to the resource URL when unset.""" + aud = (os.environ.get("FAMILIAR_OAUTH_AUDIENCE") or "").strip() + return aud or oauth_resource() + + +def oauth_resource() -> str: + """This server's public base URL (the OAuth *resource*), e.g. https://host. + + Used as the RemoteAuthProvider base_url and advertised in the + protected-resource metadata. Falls back to FAMILIAR_HTTP_PATH's origin only + if explicitly set; otherwise empty (serve will require it in oauth mode). + """ + return (os.environ.get("FAMILIAR_OAUTH_RESOURCE") or "").strip() + + +def oauth_jwks_uri() -> str: + """Explicit JWKS URI, or empty to discover it from the issuer at start.""" + return (os.environ.get("FAMILIAR_OAUTH_JWKS_URI") or "").strip() + + def data_dir() -> Path: """Where the encrypted token and key live. Created with restricted perms.""" override = os.environ.get("FAMILIAR_DATA_DIR") diff --git a/src/familiar/server.py b/src/familiar/server.py index 8c52747..22e075d 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -8,8 +8,11 @@ same `mcp` instance. from __future__ import annotations import datetime as _dt +import sys from fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse from . import ( __version__, @@ -47,6 +50,16 @@ mcp: FastMCP = FastMCP( ) +@mcp.custom_route("/healthz", methods=["GET"]) +async def healthz(_request: Request) -> JSONResponse: + """Unauthenticated liveness probe for Docker / reverse-proxy healthchecks. + + Only reachable over the HTTP transport; on stdio the route is never served. + Deliberately outside the bearer-auth gate so a probe needs no token. + """ + return JSONResponse({"status": "ok", "server": "familiar", "version": __version__}) + + @mcp.tool def ping() -> dict[str, str]: """Health check. Confirms the familiar server is alive and reports its version. @@ -126,21 +139,96 @@ def _bearer_verifier(token: str): 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. +def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str: + """Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset.""" + if jwks_uri: + return jwks_uri + import httpx - 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. + disco = issuer.rstrip("/") + "/.well-known/openid-configuration" + try: + resp = httpx.get(disco, timeout=10.0) + resp.raise_for_status() + uri = resp.json().get("jwks_uri") + except Exception as exc: # noqa: BLE001 — surface any discovery failure as a clean exit + raise SystemExit( + f"OAuth discovery failed at {disco}: {exc}. Set FAMILIAR_OAUTH_JWKS_URI explicitly." + ) from exc + if not uri: + raise SystemExit(f"OIDC metadata at {disco} has no jwks_uri; set FAMILIAR_OAUTH_JWKS_URI.") + return uri + + +def _oauth_provider(verifier=None): + """Build the OAuth 2.1 resource-server provider (validates external JWTs). + + familiar acts as a *resource server* only: it validates incoming JWT access + tokens against an external issuer's JWKS and advertises + ``/.well-known/oauth-protected-resource`` so OAuth-only clients can discover + the authorization server. It is NOT an authorization server and does no DCR. + ``verifier`` is injectable for tests; production builds a JWTVerifier. """ + from pydantic import AnyHttpUrl + + from fastmcp.server.auth import RemoteAuthProvider + + issuer = config.oauth_issuer() + resource = config.oauth_resource() + if not issuer or not resource: + raise SystemExit( + "oauth mode needs FAMILIAR_OAUTH_ISSUER (the authorization server) and " + "FAMILIAR_OAUTH_RESOURCE (this server's public https URL)." + ) + if verifier is None: + from fastmcp.server.auth.providers.jwt import JWTVerifier + + jwks = _resolve_jwks_uri(issuer, config.oauth_jwks_uri()) + verifier = JWTVerifier(jwks_uri=jwks, issuer=issuer, audience=config.oauth_audience()) + return RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl(issuer)], + base_url=resource, + ) + + +def _refuse_no_token(host: str) -> "SystemExit": + where = "a public interface" if not config.is_loopback(host) else "loopback" + return SystemExit( + f"Refusing to start the HTTP server on {host!r} ({where}) without a bearer " + "token. Set FAMILIAR_HTTP_TOKEN to a long random secret, switch to " + "FAMILIAR_AUTH_MODE=oauth, or — only if auth is handled upstream — set " + "FAMILIAR_ALLOW_INSECURE_BIND=true to run unauthenticated." + ) + + +def configure_http(*, host: str | None = None, token: str | None = None, verifier=None) -> str: + """Prepare the shared ``mcp`` instance for HTTP serving; return the mode/token. + + Attaches the auth provider for the configured ``FAMILIAR_AUTH_MODE`` and + switches writes to their network default (disabled unless + FAMILIAR_ENABLE_WRITES is explicitly truthy). Fail-closed: a bind without a + token (bearer mode) raises ``SystemExit`` unless FAMILIAR_ALLOW_INSECURE_BIND + is set. ``verifier`` is injectable for oauth-mode tests. + """ + host = host if host is not None else config.HTTP_HOST + writes.set_http_context(True) + + if config.auth_mode() == "oauth": + mcp.auth = _oauth_provider(verifier=verifier) + return "oauth" + 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) + if config.allow_insecure_bind(): + print( + f"WARNING: serving familiar on {host!r} with NO authentication " + "(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this " + "port has full access to your account.", + file=sys.stderr, + ) + mcp.auth = None + return "" + raise _refuse_no_token(host) mcp.auth = _bearer_verifier(token) return token @@ -148,16 +236,17 @@ def configure_http(token: str | None = None) -> str: 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. + """Run familiar over the streamable-HTTP transport. 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. + config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default). """ - configure_http() + host = host or config.HTTP_HOST + configure_http(host=host) mcp.run( transport="http", - host=host or config.HTTP_HOST, + host=host, port=port or config.HTTP_PORT, path=path or config.HTTP_PATH, ) diff --git a/tests/test_serve.py b/tests/test_serve.py index 7acda01..a163ebd 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -79,6 +79,20 @@ def test_valid_token_passes_handshake(http_server): assert status == 200 +def test_healthz_needs_no_token(http_server): + _, app = http_server + + async def _get_health(): + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/healthz") + + resp = asyncio.run(_get_health()) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + # --------------------------------------------------------------------------- # # Start refusal without a token # --------------------------------------------------------------------------- # @@ -214,3 +228,166 @@ def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path): assert bundle["character_id"] == 42 assert bundle["character_name"] == "Pilot" assert saved["character_id"] == 42 + + +# --------------------------------------------------------------------------- # +# Fail-closed bind + insecure escape hatch +# --------------------------------------------------------------------------- # +def _reset_auth(): + server.mcp.auth = None + writes.set_http_context(False) + + +def test_public_bind_without_token_refused(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + with pytest.raises(SystemExit) as excinfo: + server.configure_http(host="0.0.0.0") + assert "public interface" in str(excinfo.value) + finally: + _reset_auth() + + +def test_loopback_without_token_still_refused(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + with pytest.raises(SystemExit): + server.configure_http(host="127.0.0.1") + finally: + _reset_auth() + + +def test_insecure_bind_escape_hatch_allows_tokenless(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "true") + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + result = server.configure_http(host="0.0.0.0") + assert result == "" + assert server.mcp.auth is None + finally: + _reset_auth() + + +# --------------------------------------------------------------------------- # +# OAuth resource-server mode +# --------------------------------------------------------------------------- # +_ISS = "https://idp.example.com" +_RES = "https://familiar.example.com" + + +@pytest.fixture +def oauth_server(monkeypatch): + """Configure oauth mode with an injected JWTVerifier over a local RSA key.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair + + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth") + monkeypatch.setenv("FAMILIAR_OAUTH_ISSUER", _ISS) + monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES) + monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", _RES) + original = server.mcp.auth + kp = RSAKeyPair.generate() + verifier = JWTVerifier(public_key=kp.public_key, issuer=_ISS, audience=_RES) + server.configure_http(host="0.0.0.0", verifier=verifier) + app = server.mcp.http_app(path="/mcp") + yield kp, app + server.mcp.auth = original + writes.set_http_context(False) + + +def test_oauth_valid_jwt_passes(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1") + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 200 + + +def test_oauth_missing_token_401(oauth_server): + _, app = oauth_server + status, www_auth = asyncio.run(_status_for(app, None)) + assert status == 401 + assert www_auth and www_auth.lower().startswith("bearer") + + +def test_oauth_wrong_audience_401(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience="https://other.example.com", subject="user1") + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 401 + + +def test_oauth_expired_token_401(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1", expires_in_seconds=-60) + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 401 + + +def test_oauth_protected_resource_metadata(oauth_server): + _, app = oauth_server + + async def _get_metadata(): + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/.well-known/oauth-protected-resource/mcp") + + resp = asyncio.run(_get_metadata()) + assert resp.status_code == 200 + body = resp.json() + assert body["resource"] == f"{_RES}/mcp" + assert body["authorization_servers"] == [f"{_ISS}/"] + + +def test_oauth_requires_issuer_and_resource(monkeypatch): + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth") + monkeypatch.delenv("FAMILIAR_OAUTH_ISSUER", raising=False) + monkeypatch.delenv("FAMILIAR_OAUTH_RESOURCE", raising=False) + try: + with pytest.raises(SystemExit) as excinfo: + server.configure_http(host="0.0.0.0") + assert "FAMILIAR_OAUTH_ISSUER" in str(excinfo.value) + finally: + _reset_auth() + + +def test_resolve_jwks_uri_passthrough(): + assert server._resolve_jwks_uri(_ISS, "https://idp/keys") == "https://idp/keys" + + +# --------------------------------------------------------------------------- # +# Config readers for the new env vars +# --------------------------------------------------------------------------- # +def test_is_loopback(): + assert config.is_loopback("127.0.0.1") + assert config.is_loopback("localhost") + assert not config.is_loopback("0.0.0.0") + assert not config.is_loopback("192.168.1.10") + + +def test_auth_mode_default_and_values(monkeypatch): + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + assert config.auth_mode() == "bearer" + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "OAuth") + assert config.auth_mode() == "oauth" + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "nonsense") + assert config.auth_mode() == "bearer" + + +def test_allow_insecure_bind(monkeypatch): + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + assert config.allow_insecure_bind() is False + monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes") + assert config.allow_insecure_bind() is True + + +def test_oauth_audience_defaults_to_resource(monkeypatch): + monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES) + monkeypatch.delenv("FAMILIAR_OAUTH_AUDIENCE", raising=False) + assert config.oauth_audience() == _RES + monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", "custom-aud") + assert config.oauth_audience() == "custom-aud" -- 2.54.0 From 39ce8005c0099da8925eb1d3c7ff16f8c1b048e0 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:26:17 +0200 Subject: [PATCH 05/10] feat(docker): image, compose, and env for remote-HTTP deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/Dockerfile: multi-stage uv wheel build; runtime runs `familiar serve` as a non-root user (uid 1000) on port 8080, /data volume for the encrypted token store, and a HEALTHCHECK against /healthz. No token is baked in — serve refuses to start without FAMILIAR_HTTP_TOKEN, so it's supplied at run time. - docker-compose.yml: joins an external `proxy` network (Traefik labels for TLS, nginx/Caddy documented as alternatives), read-only rootfs, tmpfs /tmp, no-new-privileges, cap_drop ALL, non-root, /healthz healthcheck. Pulls the published FAMILIAR_IMAGE with a local build as fallback; writes default off. - .env.example: every FAMILIAR_* knob incl. the bearer token, auth mode, the OAuth resource-server settings, and the insecure-bind escape hatch. - .dockerignore: keep local state (data/, *.enc, key) and dev files out. --- .dockerignore | 26 +++++++++++++++ .env.example | 46 ++++++++++++++++++++++++++ docker-compose.yml | 80 ++++++++++++++++++++++++++++++++++++++++++++++ docker/Dockerfile | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c0fdfa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Keep the build context small and never ship local state or secrets. +.git +.gitea +.venv +**/__pycache__/ +*.pyc +.pytest_cache +.ruff_cache +.coverage +htmlcov +dist +build +*.egg-info + +# Local EVE token store / data dir — must never end up in an image layer. +data +*.enc +key + +# Docs, tests, and dev tooling aren't needed at runtime. +tests +doc +docker-compose.yml +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..96527dc --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# familiar — remote HTTP / Docker deployment settings. +# Copy to .env and fill in. .env is gitignored; never commit real secrets. + +# --- Image (published to Gitea by .gitea/workflows/docker.yml) -------------- # +# Prod channel: :latest or :main. Dev channel: :dev. Or pin :sha-<12>. +FAMILIAR_IMAGE=git.hiddenden.cafe/hiddenden/familiar:latest + +# --- Bind + path ----------------------------------------------------------- # +# The image binds 0.0.0.0:8080 internally; only the reverse proxy reaches it. +# FAMILIAR_HTTP_PATH must match your proxy's location block. +FAMILIAR_HTTP_PATH=/mcp + +# --- Auth mode ------------------------------------------------------------- # +# bearer (default) — a single static token (below). Works with every client. +# oauth — validate external-issuer JWTs; needed only for the Claude web +# connector (requires an external IdP with DCR). See doc/clients/claude-web.md. +FAMILIAR_AUTH_MODE=bearer + +# --- Bearer auth (REQUIRED when FAMILIAR_AUTH_MODE=bearer) ------------------ # +# Token clients send as `Authorization: Bearer `. Generate a strong one: +# openssl rand -hex 32 +FAMILIAR_HTTP_TOKEN= + +# Escape hatch: allow a public (0.0.0.0) bind with NO token. Leave false unless +# authentication is handled entirely upstream. +# FAMILIAR_ALLOW_INSECURE_BIND=false + +# --- OAuth 2.1 resource server (only when FAMILIAR_AUTH_MODE=oauth) --------- # +# FAMILIAR_OAUTH_ISSUER=https://idp.example.com/application/o/familiar/ +# FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com # this server's public URL +# FAMILIAR_OAUTH_AUDIENCE=https://familiar.example.com # defaults to RESOURCE +# FAMILIAR_OAUTH_JWKS_URI= # optional; else discovered from issuer + +# --- Data / writes --------------------------------------------------------- # +# Host path holding the encrypted token store (token.enc + key), audit log, and +# write toggle. Produced by `familiar login` on a machine with a browser (or +# `familiar login --no-browser` on the host). +FAMILIAR_DATA_DIR_HOST=./data + +# Writes off by default on a public instance. Set true to allow guarded writes. +FAMILIAR_ENABLE_WRITES=false + +# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # +# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md. +FAMILIAR_DOMAIN=familiar.example.com +TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5318e68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + familiar: + # Pull the image published to Gitea by .gitea/workflows/docker.yml, or build + # locally with `docker compose build`. Pick the channel via .env: + # FAMILIAR_IMAGE (:latest / :main for prod, :dev for the dev channel). + image: ${FAMILIAR_IMAGE:-git.hiddenden.cafe/hiddenden/familiar:latest} + build: + context: . + dockerfile: docker/Dockerfile + container_name: familiar + restart: unless-stopped + env_file: + - .env + environment: + FAMILIAR_HTTP_HOST: 0.0.0.0 + FAMILIAR_HTTP_PORT: "8080" + FAMILIAR_HTTP_PATH: ${FAMILIAR_HTTP_PATH:-/mcp} + FAMILIAR_DATA_DIR: /data + # bearer (default) or oauth. In bearer mode FAMILIAR_HTTP_TOKEN is required. + FAMILIAR_AUTH_MODE: ${FAMILIAR_AUTH_MODE:-bearer} + # Bearer token clients send as `Authorization: Bearer `. REQUIRED in + # bearer mode — `familiar serve` refuses to start on 0.0.0.0 without it + # (unless FAMILIAR_ALLOW_INSECURE_BIND=true). Leave blank for oauth mode. + FAMILIAR_HTTP_TOKEN: ${FAMILIAR_HTTP_TOKEN:-} + # OAuth 2.1 resource-server settings (only used when AUTH_MODE=oauth). + FAMILIAR_OAUTH_ISSUER: ${FAMILIAR_OAUTH_ISSUER:-} + FAMILIAR_OAUTH_RESOURCE: ${FAMILIAR_OAUTH_RESOURCE:-} + FAMILIAR_OAUTH_AUDIENCE: ${FAMILIAR_OAUTH_AUDIENCE:-} + FAMILIAR_OAUTH_JWKS_URI: ${FAMILIAR_OAUTH_JWKS_URI:-} + # Writes are off by default over HTTP; set true in .env to allow them. + FAMILIAR_ENABLE_WRITES: ${FAMILIAR_ENABLE_WRITES:-false} + # The encrypted token store (token.enc + key), audit log, and write toggle + # live under /data. Run `familiar login` (or `login --no-browser`) once, then + # point this at that data dir (or copy token.enc + key into ./data). + volumes: + - ${FAMILIAR_DATA_DIR_HOST:-./data}:/data + # Not published to the host — only reachable on the internal proxy network, + # where your TLS reverse proxy terminates HTTPS and forwards to :8080. + expose: + - "8080" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + user: "1000:1000" + networks: + - proxy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5).raise_for_status()", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + labels: + # --- Traefik TLS termination (Let's Encrypt) -------------------------- # + # Enable if your reverse proxy is Traefik on the external `proxy` network. + # Set FAMILIAR_DOMAIN and TRAEFIK_CERTRESOLVER in .env. For nginx/Caddy + # instead, ignore these labels — see doc/deployment.md. + traefik.enable: "true" + traefik.docker.network: proxy + traefik.http.routers.familiar.rule: Host(`${FAMILIAR_DOMAIN:-familiar.example.com}`) + traefik.http.routers.familiar.entrypoints: websecure + traefik.http.routers.familiar.tls: "true" + traefik.http.routers.familiar.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-letsencrypt} + traefik.http.services.familiar.loadbalancer.server.port: "8080" + +networks: + # Shared reverse-proxy network (Traefik/Caddy). Create once with: + # docker network create proxy + proxy: + external: true diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..0024c7c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- # +# Build stage: build a wheel from the source tree with uv. +# --------------------------------------------------------------------------- # +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +# Files needed to build the wheel (pyproject references README.md + LICENSE). +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src + +RUN uv build --wheel --out-dir /dist + +# --------------------------------------------------------------------------- # +# Runtime stage: minimal, non-root image that runs `familiar serve` (HTTP). +# --------------------------------------------------------------------------- # +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + # Bind on all interfaces inside the container; a TLS reverse proxy fronts it. + FAMILIAR_HTTP_HOST=0.0.0.0 \ + FAMILIAR_HTTP_PORT=8080 \ + FAMILIAR_HTTP_PATH=/mcp \ + # Token store + audit log + write toggle live here — mount a persistent volume. + FAMILIAR_DATA_DIR=/data + # FAMILIAR_HTTP_TOKEN is intentionally NOT baked in: `familiar serve` refuses + # to start without it, so it must be supplied at run time (docker run -e ...). + # Writes stay off by default over HTTP; set FAMILIAR_ENABLE_WRITES=true to opt in. + +WORKDIR /app + +# Non-root runtime user; /data owned by it so token refresh can write. +RUN useradd -m -u 1000 -s /usr/sbin/nologin familiar \ + && mkdir -p /data \ + && chown -R familiar:familiar /data + +COPY --from=builder /dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl + +USER familiar + +EXPOSE 8080 +VOLUME ["/data"] + +# Liveness against the unauthenticated /healthz route (no token needed). +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import os,httpx; httpx.get(f\"http://127.0.0.1:{os.environ.get('FAMILIAR_HTTP_PORT','8080')}/healthz\", timeout=5).raise_for_status()" || exit 1 + +CMD ["familiar", "serve"] -- 2.54.0 From eb2922f63c1621e46e43c0f71128712c4a251251 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:26:17 +0200 Subject: [PATCH 06/10] ci(docker): build, smoke-test, and publish the remote-HTTP image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New .gitea/workflows/docker.yml (alongside the untouched ci.yml wheel publish). Runs lint + test, then builds the image and smoke-tests it — non-root uid, /healthz 200, MCP 401 without a token and 200 with a valid bearer. On a merge to main/dev it pushes to git.hiddenden.cafe/hiddenden/familiar (main -> :latest + :main, dev -> :dev, plus :sha-<12>) via the existing REGISTRY_TOKEN, then does a scoped cleanup. The two workflows are independent and don't interfere. --- .gitea/workflows/docker.yml | 131 ++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .gitea/workflows/docker.yml diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..38079d8 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,131 @@ +name: docker + +# Build + smoke-test the remote-HTTP (`familiar serve`) image on every push/PR; +# push to the Gitea container registry only on a real merge to main/dev. +# Runs alongside ci.yml (which publishes the Python wheel) — they don't overlap. +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group dev + - name: Ruff (lint + format check) + run: | + uv run ruff check . + uv run ruff format --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group dev + - name: Run tests with coverage (fails under 80%) + run: uv run pytest + + docker: + needs: [lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Compute image name & tags + id: meta + shell: bash + run: | + IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + if [ "${GITHUB_REF_NAME}" = "main" ]; then + echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" + else + echo "branch_tags=${IMAGE}:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Build image + shell: bash + run: docker build -f docker/Dockerfile -t "${{ steps.meta.outputs.sha_tag }}" . + + - name: Smoke-test image + shell: bash + run: | + set -euo pipefail + IMG="${{ steps.meta.outputs.sha_tag }}" + TOKEN="smoke-$(date +%s)" + + # 1. Runs as a non-root user (uid 1000). + uid=$(docker run --rm --entrypoint id "$IMG" -u) + echo "container uid: $uid" + [ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; } + + # 2. Boot the server and probe it over HTTP. + docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG" + for i in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:18080/healthz" >/dev/null 2>&1; then break; fi + sleep 1 + done + + echo "== /healthz (expect 200) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18080/healthz") + [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } + + echo "== MCP without token (expect 401) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ + -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}') + [ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; } + + echo "== MCP with valid token (expect 200) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}') + [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs familiar-smoke; exit 1; } + + echo "Image smoke-test passed." + + - name: Log in to Gitea Container Registry + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev') + uses: docker/login-action@v3 + with: + registry: git.hiddenden.cafe + username: ${{ github.actor }} + # PAT with write:package scope (the auto GITEA_TOKEN lacks it here). + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Tag & push + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev') + shell: bash + run: | + for tag in ${{ steps.meta.outputs.branch_tags }} ${{ steps.meta.outputs.sha_tag }}; do + docker tag "${{ steps.meta.outputs.sha_tag }}" "$tag" + docker push "$tag" + echo "Pushed $tag" + done + + # Always runs — removes only what this run created (the self-hosted runner + # may share the host Docker daemon, so no global prune of other services). + - name: Cleanup + if: always() + shell: bash + run: | + docker rm -f familiar-smoke || true + docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true + docker image prune -f || true + docker builder prune -f || true -- 2.54.0 From df9e06a3e7514ad9f3fd7f6c6ed4113b68a13cf1 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:26:33 +0200 Subject: [PATCH 07/10] docs: deployment guide + per-client setup + connect matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CLAUDE.md | 11 ++- README.md | 41 ++++------ doc/README.md | 2 + doc/clients/README.md | 18 +++++ doc/clients/claude-code.md | 28 +++++++ doc/clients/claude-cowork.md | 9 +++ doc/clients/claude-desktop.md | 38 +++++++++ doc/clients/claude-web.md | 39 ++++++++++ doc/clients/mistral.md | 17 +++++ doc/clients/vscode.md | 38 +++++++++ doc/deployment.md | 140 ++++++++++++++++++++++++++++++++++ doc/getting-started.md | 14 ++++ 12 files changed, 367 insertions(+), 28 deletions(-) create mode 100644 doc/clients/README.md create mode 100644 doc/clients/claude-code.md create mode 100644 doc/clients/claude-cowork.md create mode 100644 doc/clients/claude-desktop.md create mode 100644 doc/clients/claude-web.md create mode 100644 doc/clients/mistral.md create mode 100644 doc/clients/vscode.md create mode 100644 doc/deployment.md diff --git a/CLAUDE.md b/CLAUDE.md index bdf29b5..41c33af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,14 @@ 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. -**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). +**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/](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/`. 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](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`. @@ -48,5 +55,5 @@ Tests use [tests/conftest.py](tests/conftest.py): the `mock_esi` fixture install - 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. -- CI (`.gitea/workflows/ci.yml`) runs lint + test on every PR/push; on a merge to main/dev it builds and publishes to the Gitea PyPI registry (auth via the `REGISTRY_TOKEN` secret; twine must NOT use `--skip-existing`). dev publishes a unique `.devN` pre-release. +- 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. diff --git a/README.md b/README.md index a21605e..06282e6 100644 --- a/README.md +++ b/README.md @@ -99,39 +99,28 @@ Environment variables: **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 +By default familiar authenticates with a static bearer token (`FAMILIAR_AUTH_MODE=bearer`). For clients that only speak OAuth (the claude.ai web connector), there's an opt-in `oauth` mode where familiar acts as an OAuth 2.1 resource server in front of your own IdP — see [claude-web](doc/clients/claude-web.md). -Streamable HTTP keeps a long-lived response open, so disable proxy buffering and give it a generous read timeout: +### Deploy with Docker -```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 -} -``` +CI builds and publishes a hardened image on every merge to `dev`/`main` (`git.hiddenden.cafe/hiddenden/familiar`, tags `:dev`, `:main`/`:latest`). It runs `familiar serve` non-root on port `8080`, has a `/healthz` healthcheck, and keeps the encrypted token store under a `/data` volume. A [`docker-compose.yml`](docker-compose.yml) + [`.env.example`](.env.example) are included. -### Add characters on a headless server +**Full guide — Docker, reverse proxy (nginx / Caddy / Traefik), TLS, token provisioning, and the env-var reference — is in [doc/deployment.md](doc/deployment.md).** -`familiar login` opens a browser and captures the callback on localhost, which needs a desktop. On a headless box you have two options: +### Connect a client -1. **Headless login** — print the SSO URL, authorize in any browser, paste the redirected URL back: +Pick your client for exact steps: - ```bash - uv run familiar login --no-browser - ``` +| Client | Auth | Guide | +| --- | --- | --- | +| Claude Code | Bearer | [doc/clients/claude-code.md](doc/clients/claude-code.md) | +| VS Code (Copilot) | Bearer | [doc/clients/vscode.md](doc/clients/vscode.md) | +| Mistral Le Chat | Bearer | [doc/clients/mistral.md](doc/clients/mistral.md) | +| Claude Desktop | Bearer (via `mcp-remote`) | [doc/clients/claude-desktop.md](doc/clients/claude-desktop.md) | +| Claude Cowork | Bearer (via `mcp-remote`) | [doc/clients/claude-cowork.md](doc/clients/claude-cowork.md) | +| Claude web (claude.ai) | OAuth 2.1 (external IdP) | [doc/clients/claude-web.md](doc/clients/claude-web.md) | - 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. +Bearer works everywhere except the claude.ai web connector, which requires OAuth with Dynamic Client Registration. The quickest path for a Claude user who doesn't want to run an IdP is the **Desktop** app with a bearer token. ## Roadmap diff --git a/doc/README.md b/doc/README.md index 08c5152..94b2cbe 100644 --- a/doc/README.md +++ b/doc/README.md @@ -38,6 +38,8 @@ fittings, fleet, UI windows, PI) gated behind a safety choke point. | Document | What's in it | | --- | --- | | [Getting Started](getting-started.md) | Install with `uv`, log in via EVE SSO, connect to Claude, first queries. | +| [Deployment](deployment.md) | Run familiar as a remote MCP server: Docker, reverse proxy + TLS, bearer/OAuth, env reference. | +| [Client setup](clients/) | Exact connection steps per client — Claude Code, VS Code, Desktop, Cowork, web, Mistral. | | [Architecture](architecture.md) | How the server is assembled, the ESI/auth/charutil/writes layers, the module pattern. | | [Authentication](authentication.md) | EVE SSO PKCE flow, the encrypted multi-character store, scopes, CLI commands. | | [Write Safety](write-safety.md) | The `writes.guard` choke point, dry-run previews, the audit log, enabling/disabling. | diff --git a/doc/clients/README.md b/doc/clients/README.md new file mode 100644 index 0000000..b689eff --- /dev/null +++ b/doc/clients/README.md @@ -0,0 +1,18 @@ +# Connect a client + +Exact steps to point each MCP client at a familiar server deployed over HTTPS +(see [deployment](../deployment.md) first). Most clients use a **bearer token** +(`FAMILIAR_HTTP_TOKEN`); only the claude.ai web connector needs **OAuth**. + +| Client | Auth | Guide | +| --- | --- | --- | +| Claude Code | Bearer | [claude-code.md](claude-code.md) | +| VS Code (Copilot) | Bearer | [vscode.md](vscode.md) | +| Mistral Le Chat | Bearer | [mistral.md](mistral.md) | +| Claude Desktop | Bearer (via `mcp-remote`) | [claude-desktop.md](claude-desktop.md) | +| Claude Cowork | Bearer (via `mcp-remote`) | [claude-cowork.md](claude-cowork.md) | +| Claude web (claude.ai) | OAuth 2.1 (external IdP) | [claude-web.md](claude-web.md) | + +Bearer works everywhere except the claude.ai web connector, which requires +OAuth 2.1 with Dynamic Client Registration. If you're a Claude user who doesn't +want to run an IdP, use the **Desktop** app with a bearer token instead. diff --git a/doc/clients/claude-code.md b/doc/clients/claude-code.md new file mode 100644 index 0000000..fd7804a --- /dev/null +++ b/doc/clients/claude-code.md @@ -0,0 +1,28 @@ +# Connect Claude Code + +**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)). + +Claude Code speaks HTTP MCP with custom headers directly. Add the server: + +```bash +claude mcp add --transport http familiar https://familiar.yourdomain.tld/mcp \ + --header "Authorization: Bearer " +``` + +- `` is your `FAMILIAR_HTTP_TOKEN`. +- Use the full URL including the `FAMILIAR_HTTP_PATH` (default `/mcp`). + +Check it: + +```bash +claude mcp list # familiar should show as connected +``` + +Then ask Claude Code to call `ping`, or "what's Tritanium worth in Jita?". + +To scope it to the current project instead of your user config, add `--scope project` (writes a `.mcp.json` in the repo). Remove it with `claude mcp remove familiar`. + +## Troubleshooting + +- **401** — the token is wrong or missing; re-run `claude mcp add` with the correct `Authorization` header. +- **Hangs / times out** — your reverse proxy is likely buffering; set `proxy_buffering off;` and a long read timeout (see [deployment](../deployment.md#tls-reverse-proxy)). diff --git a/doc/clients/claude-cowork.md b/doc/clients/claude-cowork.md new file mode 100644 index 0000000..483d2ee --- /dev/null +++ b/doc/clients/claude-cowork.md @@ -0,0 +1,9 @@ +# Connect Claude Cowork + +**Auth:** bearer token, via a local bridge. + +Cowork shares Claude Desktop's connector plumbing, so the setup is identical: bridge the remote HTTP server to a local stdio command with `mcp-remote` so the `Authorization: Bearer ` header gets forwarded. + +Follow **[Connect Claude Desktop](claude-desktop.md)** — the same `claude_desktop_config.json` entry (the `npx mcp-remote … --header "Authorization: Bearer "` block) applies. After editing, restart Cowork. + +If you've configured it once for Desktop on the same machine, Cowork picks up the same config. diff --git a/doc/clients/claude-desktop.md b/doc/clients/claude-desktop.md new file mode 100644 index 0000000..035b889 --- /dev/null +++ b/doc/clients/claude-desktop.md @@ -0,0 +1,38 @@ +# Connect Claude Desktop + +**Auth:** bearer token, via a local bridge. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)), plus Node.js (`npx`) on the machine running Claude Desktop. + +Claude Desktop's **Connectors** UI can add a remote MCP URL but **cannot attach a bearer header**. So bridge the remote HTTP server to a local stdio command with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which forwards the `Authorization` header. + +Edit `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "familiar": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://familiar.yourdomain.tld/mcp", + "--header", + "Authorization: Bearer " + ] + } + } +} +``` + +Config file location: + +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux:** `~/.config/Claude/claude_desktop_config.json` + +Restart Claude Desktop fully (quit, not just close the window). familiar appears in the tools/plug icon. + +## Troubleshooting + +- **Server fails to start** — ensure Node.js is installed and `npx` is on PATH; the first run downloads `mcp-remote`. +- **401** — fix the token in the `--header` argument, then restart. +- **Want native (no bridge)?** Only the OAuth path is header-free, and Desktop's connector is happiest with bearer via this bridge — keep `mcp-remote` unless you've set up [oauth mode](claude-web.md). diff --git a/doc/clients/claude-web.md b/doc/clients/claude-web.md new file mode 100644 index 0000000..a91f1e6 --- /dev/null +++ b/doc/clients/claude-web.md @@ -0,0 +1,39 @@ +# Connect Claude web (claude.ai) + +**Auth:** OAuth 2.1 only. **Prereq:** familiar in `oauth` mode behind HTTPS, plus an external identity provider. + +The claude.ai custom-connector flow speaks **OAuth 2.1 with Dynamic Client Registration (DCR)** and does **not** accept a static bearer token. familiar itself is only a **resource server** — it validates JWTs and advertises where to authenticate, but it is **not** an authorization server and does no DCR. So Claude web needs an external IdP that supports DCR (e.g. **Authentik** or **Keycloak**) sitting in front. + +> **Simpler alternative:** if you don't want to run an IdP, use the **Claude Desktop** app with a bearer token instead — see [Connect Claude Desktop](claude-desktop.md). Bearer is easier for every client except the claude.ai web connector. + +## 1. Set up the IdP (external) + +On your IdP, create an application/provider for familiar that: + +- issues **JWT access tokens** signed with a key published at a **JWKS** endpoint, +- sets the token **audience** to familiar's public URL (the resource), +- supports **Dynamic Client Registration** so claude.ai can register itself. + +Note the issuer URL and (optionally) the JWKS URL. + +## 2. Configure familiar in oauth mode + +```bash +FAMILIAR_AUTH_MODE=oauth +FAMILIAR_OAUTH_ISSUER=https://idp.yourdomain.tld/application/o/familiar/ +FAMILIAR_OAUTH_RESOURCE=https://familiar.yourdomain.tld +# FAMILIAR_OAUTH_AUDIENCE defaults to the resource URL; override if your IdP differs. +# FAMILIAR_OAUTH_JWKS_URI is discovered from the issuer's OIDC metadata if unset. +``` + +No `FAMILIAR_HTTP_TOKEN` is needed in oauth mode. familiar serves the discovery document at `https://familiar.yourdomain.tld/.well-known/oauth-protected-resource/mcp`, and unauthenticated MCP calls return `401` with a `WWW-Authenticate: Bearer` header pointing at your issuer. + +## 3. Add the connector + +In claude.ai: **Settings → Connectors → Add custom connector**, URL `https://familiar.yourdomain.tld/mcp`. Claude discovers your IdP from the metadata, registers via DCR, and runs the OAuth login. Approve familiar's scopes and you're connected. + +## Troubleshooting + +- **"couldn't connect" immediately** — familiar isn't in `oauth` mode, or `FAMILIAR_OAUTH_RESOURCE`/`ISSUER` are unset; the metadata endpoint must return 200. +- **Login loops / invalid token** — audience mismatch: your IdP's token `aud` must equal `FAMILIAR_OAUTH_RESOURCE` (or your `FAMILIAR_OAUTH_AUDIENCE`). +- **DCR fails** — the IdP doesn't have Dynamic Client Registration enabled; claude.ai requires it. diff --git a/doc/clients/mistral.md b/doc/clients/mistral.md new file mode 100644 index 0000000..721d67a --- /dev/null +++ b/doc/clients/mistral.md @@ -0,0 +1,17 @@ +# Connect Mistral Le Chat + +**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)). + +Mistral's [chat.mistral.ai](https://chat.mistral.ai) supports remote MCP servers with a bearer token natively. + +1. Open **Intelligence → Connectors → Add custom connector**. +2. **URL:** `https://familiar.yourdomain.tld/mcp` (include the `FAMILIAR_HTTP_PATH`, default `/mcp`). +3. **Authentication:** choose **Bearer Token** and paste your `FAMILIAR_HTTP_TOKEN`. +4. Save, then enable the connector in a conversation. + +Ask Le Chat to call `ping`, or "compare Plex prices across trade hubs". + +## Troubleshooting + +- **401 / connector won't enable** — wrong or missing token; re-check the Bearer Token value. +- **Timeouts** — reverse-proxy buffering; set `proxy_buffering off;` and a long read timeout (see [deployment](../deployment.md#tls-reverse-proxy)). diff --git a/doc/clients/vscode.md b/doc/clients/vscode.md new file mode 100644 index 0000000..6d33a00 --- /dev/null +++ b/doc/clients/vscode.md @@ -0,0 +1,38 @@ +# Connect VS Code (Copilot / MCP) + +**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)). + +VS Code's MCP support reads an **`http`** server entry with custom headers. Put it in **`.vscode/mcp.json`** (per-workspace) or your user `mcp.json`. + +> ⚠️ Use **`.vscode/mcp.json`**, not a workspace-root **`.mcp.json`**. The workspace `.mcp.json` format ignores `headers`, so the bearer token never gets sent and you'll get a silent 401. This is a known VS Code quirk. + +`.vscode/mcp.json`: + +```json +{ + "inputs": [ + { + "id": "familiar_token", + "type": "promptString", + "description": "familiar bearer token", + "password": true + } + ], + "servers": { + "familiar": { + "type": "http", + "url": "https://familiar.yourdomain.tld/mcp", + "headers": { + "Authorization": "Bearer ${input:familiar_token}" + } + } + } +} +``` + +On first use VS Code prompts for the token (masked, thanks to `password: true`) and reuses it. Start the server from the MCP view or the `MCP: List Servers` command, then use it from Copilot Chat's agent mode. + +## Troubleshooting + +- **Silent 401 / "no tools"** — you're almost certainly in a root `.mcp.json`; move the config to `.vscode/mcp.json` so `headers` is honored. +- **401 after entering a token** — wrong token; clear it via `MCP: Reset Cached Tokens` (or restart) and re-enter. diff --git a/doc/deployment.md b/doc/deployment.md new file mode 100644 index 0000000..74a84bf --- /dev/null +++ b/doc/deployment.md @@ -0,0 +1,140 @@ +# Deployment — familiar as a remote MCP server + +familiar runs two ways from the **same** code and token store: + +1. **Local (stdio)** — the default. How Claude Desktop / Claude Code / Cowork launch it. Nothing here changes; see [getting-started](getting-started.md). +2. **Remote (HTTP)** — opt-in via `familiar serve`. Streamable HTTP behind a TLS reverse proxy, gated by a bearer token (or external OAuth). This page covers (2). + +Once it's running, see [doc/clients/](clients/) for exact per-client setup. + +## Secure defaults + +- **stdio unless told otherwise.** `familiar serve` (not the bare `familiar`) starts HTTP. +- **Loopback bind by default** (`127.0.0.1`). The Docker image binds `0.0.0.0` so the reverse proxy can reach it. +- **Fail closed.** familiar refuses to start without authentication. In bearer mode that means no `FAMILIAR_HTTP_TOKEN` → it won't boot; a non-loopback bind without a token is called out explicitly. The only override is the explicit `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch. +- **Writes off over HTTP.** A network-reachable, LLM-driven endpoint keeps writes disabled unless you set `FAMILIAR_ENABLE_WRITES=true`. Local stdio keeps writes on. +- **Token never in the URL.** Clients authenticate with an `Authorization: Bearer ` header only. + +## Authentication modes + +`FAMILIAR_AUTH_MODE` picks how the HTTP transport authenticates: + +- **`bearer`** (default) — one static token in `FAMILIAR_HTTP_TOKEN`. Simple, and every client below except Claude web supports it. Recommended. +- **`oauth`** — familiar runs as an OAuth 2.1 **resource server**: it validates incoming JWT access tokens against an external issuer's JWKS and serves `/.well-known/oauth-protected-resource/` so OAuth-only clients can discover the authorization server. familiar is **not** an authorization server and does **no** Dynamic Client Registration — you must run an external IdP that does (Authentik, Keycloak, …). Only needed for the native **Claude web** connector. See [doc/clients/claude-web.md](clients/claude-web.md). + +## Environment variables + +| Variable | Default | Meaning | +| --- | --- | --- | +| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address (image sets `0.0.0.0`). | +| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. | +| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. | +| `FAMILIAR_AUTH_MODE` | `bearer` | `bearer` or `oauth`. | +| `FAMILIAR_HTTP_TOKEN` | *(unset)* | Bearer token. **Required** in bearer mode. | +| `FAMILIAR_ALLOW_INSECURE_BIND` | `false` | Allow a bind without a token (don't). | +| `FAMILIAR_OAUTH_ISSUER` | *(unset)* | Authorization-server URL (oauth mode). | +| `FAMILIAR_OAUTH_RESOURCE` | *(unset)* | This server's public URL, e.g. `https://familiar.example.com` (oauth mode). | +| `FAMILIAR_OAUTH_AUDIENCE` | = resource | Expected token audience (oauth mode). | +| `FAMILIAR_OAUTH_JWKS_URI` | *(discovered)* | JWKS URL; if unset, discovered from the issuer's OIDC metadata. | +| `FAMILIAR_DATA_DIR` | per-user | Where `token.enc` + `key` + `audit.log` + write toggle live. Image sets `/data`. | +| `FAMILIAR_ENABLE_WRITES` | on (stdio) / off (HTTP) | `true` allows guarded writes; over HTTP it's off unless set. | + +`/healthz` is an unauthenticated liveness probe (used by the Docker healthcheck and reverse-proxy probes). It returns only `{"status":"ok","server":"familiar","version":"…"}` — no account data. Every MCP call under `FAMILIAR_HTTP_PATH` requires authentication. + +## One-time login (why it happens on the host) + +EVE SSO login is a browser PKCE flow with a `localhost` callback — awkward inside a container. Authenticate **once on a machine with a browser**, then hand the resulting token store to the container. The container only ever *refreshes* tokens. + +```bash +uv run familiar login # opens the browser, writes token.enc + key +uv run familiar status # confirm the character(s) +``` + +Then make that data dir available to the container. Point `FAMILIAR_DATA_DIR_HOST` at it, or copy `token.enc` + `key` into `./data`: + +```bash +mkdir -p ./data +# Linux/macOS host default: +cp ~/.local/share/familiar/{token.enc,key} ./data/ +# Windows host default: +# copy %LOCALAPPDATA%\familiar\token.enc .\data\ ; copy %LOCALAPPDATA%\familiar\key .\data\ +``` + +**Headless host?** Use `uv run familiar login --no-browser`: it prints the SSO URL, you authorize in any browser, and paste the redirected callback URL back on stdin. The `/data` volume is mounted read-write so token refresh (which rotates the refresh token) and the audit log persist. + +## Published images (Gitea) + +`.gitea/workflows/docker.yml` builds, smoke-tests (non-root, `/healthz` 200, MCP 401 without / 200 with a token), and pushes on every push to `main`/`dev`: + +| Branch | Tags | +| --- | --- | +| `main` | `git.hiddenden.cafe/hiddenden/familiar:latest`, `:main`, `:sha-<12>` | +| `dev` | `git.hiddenden.cafe/hiddenden/familiar:dev`, `:sha-<12>` | + +Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret `REGISTRY_TOKEN`. Pick the channel with `FAMILIAR_IMAGE` in `.env`. + +## Run with Docker Compose + +```bash +cp .env.example .env +# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), FAMILIAR_DOMAIN, +# cert resolver, FAMILIAR_IMAGE (:latest prod / :dev dev channel) + +docker network create proxy # once, if your reverse proxy doesn't own it yet + +docker login git.hiddenden.cafe +docker compose pull && docker compose up -d +# ...or build locally instead: docker compose up -d --build + +docker compose logs -f familiar +``` + +The compose service joins the external `proxy` network (not published to the host), runs non-root with a `read_only` rootfs + tmpfs `/tmp`, `no-new-privileges` and `cap_drop: ALL`, and healthchecks `GET /healthz`. + +## TLS reverse proxy + +familiar terminates no TLS itself — a proxy does. Streamable HTTP holds a long-lived response open, so **disable buffering** and use a **generous read timeout** on the MCP path. + +### nginx + +```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 +} +``` + +(If you run the container on the `proxy` network instead of publishing to the host, point `proxy_pass` at `http://familiar:8080/mcp`.) + +### Caddy + +``` +familiar.yourdomain.tld { + reverse_proxy familiar:8080 +} +``` + +Caddy fetches and renews the certificate automatically and streams by default. + +### Traefik (labels included) + +`docker-compose.yml` already carries Traefik router labels. Set in `.env`: + +``` +FAMILIAR_DOMAIN=familiar.yourdomain.tld +TRAEFIK_CERTRESOLVER=letsencrypt +``` + +Traefik terminates HTTPS and forwards to the container's port 8080 on the `proxy` network. Your public URL is then `https://familiar.yourdomain.tld/mcp`. + +## Operational notes + +- **Rotate the token** by changing `FAMILIAR_HTTP_TOKEN` and restarting; update every client header. +- **Restrict inbound** at the proxy/firewall to the clients you expect. +- **Watch** `docker compose logs` for `401` auth failures and `/data/audit.log` for the write trail. +- **Re-login** (host) whenever you change requested scopes, then refresh the `/data` token store. diff --git a/doc/getting-started.md b/doc/getting-started.md index 1ad5655..1274f11 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -106,6 +106,10 @@ To reach familiar from a **remote** MCP client (for example a [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. +> This section is a quickstart. For the full production setup — Docker, reverse proxy (nginx / Caddy / +> Traefik), the bearer vs OAuth modes, and the env-var reference — see [deployment](deployment.md), and +> for exact per-client steps see [doc/clients/](clients/). + ```bash FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve ``` @@ -158,6 +162,16 @@ location /mcp { - **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. +### Run it in Docker + +CI publishes a container image on every merge to `dev`/`main` +(`git.hiddenden.cafe/hiddenden/familiar`, tags `:dev` and `:main`/`:latest`). It runs `familiar serve` +as a non-root user on port `8080`, with a `/healthz` healthcheck and the encrypted token store under a +`/data` volume. Use the bundled [`docker-compose.yml`](../docker-compose.yml): copy +[`.env.example`](../.env.example) to `.env`, set `FAMILIAR_HTTP_TOKEN`, put your logged-in store in +`./data`, then `docker compose up -d`. It publishes on `127.0.0.1:8080` only — the nginx block above +proxies to it. The container refuses to start without `FAMILIAR_HTTP_TOKEN`. + ### Add it in Mistral In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**, -- 2.54.0 From ef1567b07129c36b1ba1009c2861b209c9d87fc7 Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 5 Jul 2026 13:38:51 +0200 Subject: [PATCH 08/10] chore(deploy): drop Traefik labels, document Pangolin as the proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose service already joins the external `proxy` network and exposes :8080; the reverse proxy owns TLS and routing. Remove the Traefik router labels (and the now-unused FAMILIAR_DOMAIN / TRAEFIK_CERTRESOLVER env vars) and document Pangolin — which discovers the service on the shared network — as the proxy instead. nginx/Caddy guidance is unchanged. --- .env.example | 8 ++++---- doc/deployment.md | 15 +++++---------- docker-compose.yml | 16 +++------------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 96527dc..012ee38 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ FAMILIAR_DATA_DIR_HOST=./data # Writes off by default on a public instance. Set true to allow guarded writes. FAMILIAR_ENABLE_WRITES=false -# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # -# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md. -FAMILIAR_DOMAIN=familiar.example.com -TRAEFIK_CERTRESOLVER=letsencrypt +# --- Reverse proxy --------------------------------------------------------- # +# The container joins the external `proxy` network and exposes :8080; the proxy +# (Pangolin, or nginx/Caddy — see doc/deployment.md) terminates TLS and forwards +# to it. The public hostname and certs are configured in the proxy, not here. diff --git a/doc/deployment.md b/doc/deployment.md index 74a84bf..92369b9 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -77,8 +77,8 @@ Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret ` ```bash cp .env.example .env -# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), FAMILIAR_DOMAIN, -# cert resolver, FAMILIAR_IMAGE (:latest prod / :dev dev channel) +# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), +# FAMILIAR_IMAGE (:latest prod / :dev dev channel) docker network create proxy # once, if your reverse proxy doesn't own it yet @@ -121,16 +121,11 @@ familiar.yourdomain.tld { Caddy fetches and renews the certificate automatically and streams by default. -### Traefik (labels included) +### Pangolin -`docker-compose.yml` already carries Traefik router labels. Set in `.env`: +The compose service exposes `:8080` on the shared external `proxy` network and carries no proxy-specific labels. In Pangolin, add a resource for `familiar` that forwards to `http://familiar:8080` (target the container's `/mcp` path), and let Pangolin terminate HTTPS. Make sure Pangolin's own container is attached to the same `proxy` network so it can reach the service by name. Your public URL is then `https://familiar.yourdomain.tld/mcp`. -``` -FAMILIAR_DOMAIN=familiar.yourdomain.tld -TRAEFIK_CERTRESOLVER=letsencrypt -``` - -Traefik terminates HTTPS and forwards to the container's port 8080 on the `proxy` network. Your public URL is then `https://familiar.yourdomain.tld/mcp`. +Because streamable HTTP holds a long-lived response open, raise the read/response timeout on the familiar resource and disable response buffering if the option is exposed (same reasoning as the nginx `proxy_read_timeout` / `proxy_buffering off` above). ## Operational notes diff --git a/docker-compose.yml b/docker-compose.yml index 5318e68..3b852ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,21 +60,11 @@ services: timeout: 10s retries: 3 start_period: 10s - labels: - # --- Traefik TLS termination (Let's Encrypt) -------------------------- # - # Enable if your reverse proxy is Traefik on the external `proxy` network. - # Set FAMILIAR_DOMAIN and TRAEFIK_CERTRESOLVER in .env. For nginx/Caddy - # instead, ignore these labels — see doc/deployment.md. - traefik.enable: "true" - traefik.docker.network: proxy - traefik.http.routers.familiar.rule: Host(`${FAMILIAR_DOMAIN:-familiar.example.com}`) - traefik.http.routers.familiar.entrypoints: websecure - traefik.http.routers.familiar.tls: "true" - traefik.http.routers.familiar.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-letsencrypt} - traefik.http.services.familiar.loadbalancer.server.port: "8080" + # No proxy labels: Pangolin discovers this service on the shared `proxy` + # network and forwards to :8080 — configure the route in Pangolin, not here. networks: - # Shared reverse-proxy network (Traefik/Caddy). Create once with: + # Shared reverse-proxy network (Pangolin). Create once with: # docker network create proxy proxy: external: true -- 2.54.0 From 945e40b5d8ef37a51570b0917ecb43680d6206fe Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 5 Jul 2026 13:31:49 +0200 Subject: [PATCH 09/10] fix(ci): smoke-test the image from inside its own container via docker exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke test probed the server over the network, which fails on the self-hosted runner: the job step runs in a container that shares the host Docker socket but sits on a different network, so it can reach neither a published host port (-p lands on the runner host) nor the server container's bridge IP (different network → curl connect hangs, so the job looked stuck rather than failing). Probe from inside the server container with `docker exec` instead: it goes through the Docker socket and runs in the server's own netns, where 127.0.0.1:8080 always works regardless of runner topology. The image ships python + httpx, so no curl or extra image pull is needed. Drop the now unnecessary -p publish. --- .gitea/workflows/docker.yml | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 38079d8..5fea119 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -74,28 +74,38 @@ jobs: echo "container uid: $uid" [ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; } - # 2. Boot the server and probe it over HTTP. - docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG" + # 2. Boot the server and probe it from *inside* its own container via + # `docker exec`. On self-hosted runners the job step runs in a container + # that shares the host's Docker socket but sits on a different network, + # so it can reach neither a published host port (-p lands on the runner + # host, not this step's netns) nor the server container's bridge IP + # (different network → connect hangs). `docker exec` goes through the + # socket and runs the probe in the server's own netns, where + # 127.0.0.1:8080 always works regardless of runner topology. The image + # ships python + httpx, so no curl or extra pull is needed. + docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG" + + # probe "" -> prints the status code + probe() { docker exec familiar-smoke python -c "import httpx; print(($1).status_code)"; } + for i in $(seq 1 30); do - if curl -fsS "http://127.0.0.1:18080/healthz" >/dev/null 2>&1; then break; fi + if docker exec familiar-smoke python -c \ + "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then + break + fi sleep 1 done echo "== /healthz (expect 200) ==" - code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18080/healthz") + code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)") [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } echo "== MCP without token (expect 401) ==" - code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ - -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}') + code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})") [ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; } echo "== MCP with valid token (expect 200) ==" - code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ - -H "Authorization: Bearer $TOKEN" \ - -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}') + code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'ci','version':'1'}}})") [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs familiar-smoke; exit 1; } echo "Image smoke-test passed." -- 2.54.0 From 288c696f28a47ed5be8bc927981f3e1b3b136a76 Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 5 Jul 2026 13:55:29 +0200 Subject: [PATCH 10/10] fix(ci): give the smoke-test container a per-commit name The Docker smoke-test ran the server container under the fixed name familiar-smoke. On the self-hosted runner the job shares the host Docker daemon, so a container left behind by a killed or re-run job (or a concurrent dev push + PR) collides on that name and 'docker run' fails with exit 125. Derive the name from the commit SHA, remove any stale container of that name before starting, and thread the name through the smoke-test and cleanup steps. --- .gitea/workflows/docker.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 5fea119..e906357 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -52,6 +52,9 @@ jobs: IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + # Per-commit container name: the self-hosted runner shares the host + # Docker daemon, so a fixed name collides across concurrent/re-run jobs. + echo "container=familiar-smoke-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" if [ "${GITHUB_REF_NAME}" = "main" ]; then echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" else @@ -67,8 +70,13 @@ jobs: run: | set -euo pipefail IMG="${{ steps.meta.outputs.sha_tag }}" + CONTAINER="${{ steps.meta.outputs.container }}" TOKEN="smoke-$(date +%s)" + # Remove any container left behind by a killed/re-run job before we + # reuse the name (shared host daemon → stale containers can survive). + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + # 1. Runs as a non-root user (uid 1000). uid=$(docker run --rm --entrypoint id "$IMG" -u) echo "container uid: $uid" @@ -83,13 +91,13 @@ jobs: # socket and runs the probe in the server's own netns, where # 127.0.0.1:8080 always works regardless of runner topology. The image # ships python + httpx, so no curl or extra pull is needed. - docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG" + docker run -d --name "$CONTAINER" -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG" # probe "" -> prints the status code - probe() { docker exec familiar-smoke python -c "import httpx; print(($1).status_code)"; } + probe() { docker exec "$CONTAINER" python -c "import httpx; print(($1).status_code)"; } for i in $(seq 1 30); do - if docker exec familiar-smoke python -c \ + if docker exec "$CONTAINER" python -c \ "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then break fi @@ -98,7 +106,7 @@ jobs: echo "== /healthz (expect 200) ==" code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)") - [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } + [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; } echo "== MCP without token (expect 401) ==" code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})") @@ -106,7 +114,7 @@ jobs: echo "== MCP with valid token (expect 200) ==" code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'ci','version':'1'}}})") - [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs familiar-smoke; exit 1; } + [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs "$CONTAINER"; exit 1; } echo "Image smoke-test passed." @@ -135,7 +143,7 @@ jobs: if: always() shell: bash run: | - docker rm -f familiar-smoke || true + docker rm -f ${{ steps.meta.outputs.container }} || true docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true docker image prune -f || true docker builder prune -f || true -- 2.54.0