diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e89335 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Keep the build context small and free of secrets / local state. +.git +.gitea +.venv +venv +env +ENV +__pycache__ +*.py[cod] +*.egg-info +build +dist +downloads +wheels +.pytest_cache +.ruff_cache +.mypy_cache +htmlcov +.coverage +.coverage.* +coverage.xml +.cache + +# Secrets / local config — never bake into an image. +.env +.env.* +!.env.example + +# Local docs site / editor cruft +site +.idea +.vscode + +# Never ship the encrypted token store or audit log into the image. +data +*.enc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..560499b --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# familiar — HTTP / Docker deployment settings. +# Copy to .env and fill in. `.env` is gitignored; never commit real secrets. + +# --- Transport ------------------------------------------------------------- # +# stdio (default, local) or http (Docker / public). The image sets http. +FAMILIAR_TRANSPORT=http + +# Bind + path. In Docker the container binds 0.0.0.0 internally; the reverse +# proxy is the only thing that reaches it. Leave HTTP_HOST unset locally to keep +# the safe 127.0.0.1 default. +# FAMILIAR_HTTP_HOST=0.0.0.0 +FAMILIAR_HTTP_PORT=8000 +FAMILIAR_HTTP_PATH=/mcp + +# --- Auth (REQUIRED for any non-loopback bind) ----------------------------- # +# Bearer token clients must send as `Authorization: Bearer `. +# Generate a strong one, e.g.: python -c "import secrets; print(secrets.token_urlsafe(32))" +FAMILIAR_HTTP_AUTH_TOKEN= + +# Escape hatch to bind a public interface WITHOUT a token. Leave false. +# FAMILIAR_ALLOW_INSECURE_BIND=false + +# --- 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. +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) ------------------ # +FAMILIAR_DOMAIN=familiar.example.com +TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..0f1a8e7 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker + +# Build the container image and smoke-test it on every PR/push to main/dev. +# Publishing is opt-in: set the repo variable PUSH_IMAGE=true and provide the +# REGISTRY_* vars/secrets. Without them this only builds + smoke-tests (never +# blocks a merge on registry access). +on: + pull_request: + branches: [main, dev] + push: + branches: [main, dev] + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: docker build -f docker/Dockerfile -t familiar:ci . + + - name: Smoke-test (/healthz responds, MCP requires a token) + run: | + set -e + docker run -d --name familiar-ci \ + -e FAMILIAR_TRANSPORT=http \ + -e FAMILIAR_HTTP_HOST=0.0.0.0 \ + -e FAMILIAR_HTTP_PORT=8000 \ + -e FAMILIAR_HTTP_AUTH_TOKEN=ci-smoke-token \ + -p 8000:8000 familiar:ci + # Wait for the healthcheck endpoint. + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8000/healthz >/dev/null; then ok=1; break; fi + sleep 1 + done + test "${ok:-0}" = "1" || { echo "healthz never came up"; docker logs familiar-ci; exit 1; } + echo "healthz OK" + # MCP endpoint must reject an unauthenticated call. + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') + test "$code" = "401" || { echo "expected 401 without token, got $code"; docker logs familiar-ci; exit 1; } + echo "auth gate OK (401 without token)" + + - name: Cleanup + if: always() + run: docker rm -f familiar-ci || true + + - name: Log in to registry + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + vars.PUSH_IMAGE == 'true' + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" \ + -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Tag + push image + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + vars.PUSH_IMAGE == 'true' + run: | + # main -> :latest, dev -> :dev, both also tagged with the commit SHA. + MOVING=$([ "${{ github.ref_name }}" = "main" ] && echo latest || echo dev) + SHA=$(echo "${{ github.sha }}" | cut -c1-12) + for tag in "$MOVING" "$SHA"; do + docker tag familiar:ci "${{ vars.REGISTRY_IMAGE }}:$tag" + docker push "${{ vars.REGISTRY_IMAGE }}:$tag" + done diff --git a/CLAUDE.md b/CLAUDE.md index 05be6da..d1601e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,8 @@ uv run pytest # full suite; FAILS under 80% coverage (configured 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 # run the MCP server (stdio by default; HTTP via FAMILIAR_TRANSPORT) +uv run familiar serve # explicit alias for the above (used as the Docker CMD) uv run familiar login # browser EVE SSO login (adds a character; run once per character) uv run familiar status # list logged-in characters uv run familiar writes disable # block write actions (default: enabled) @@ -36,6 +37,8 @@ There is no separate build step; `uv build` produces the wheel (CI does this on **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. +**Transport selection — the serving choke point.** [serve.py](src/familiar/serve.py) `run()` reads `config.http_settings()` (all `FAMILIAR_*` env, no I/O) and dispatches: `stdio` (default → `mcp.run()`) or `http` (streamable HTTP via `mcp.run(transport="http", ...)`, for Docker behind a TLS reverse proxy). It **fails closed** — a non-loopback bind without `FAMILIAR_HTTP_AUTH_TOKEN` (and without the `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch) raises `InsecureBind`. HTTP auth is a FastMCP `StaticTokenVerifier` bearer token attached in `server.build_http_auth` at construction (ignored by stdio). `server.py` also adds an unauthenticated `/healthz` route for container/proxy probes. The image + `docker-compose.yml` (hardened: non-root, read-only rootfs, `cap_drop: ALL`) mount the encrypted token store at `/data` (`FAMILIAR_DATA_DIR`) and default `FAMILIAR_ENABLE_WRITES=false`; login still happens on a host with a browser. See [docs/deployment.md](docs/deployment.md). + ## Testing conventions Tests use [tests/conftest.py](tests/conftest.py): the `mock_esi` fixture installs a mock ESI client driven by a request handler, and `route(req, {substring: body})` dispatches by path substring. Async tools are driven with `asyncio.run(...)` (no async-test plugin). Authenticated-tool tests monkeypatch `auth.list_characters` / `auth.get_auth` / `auth.resolve_character` rather than touching the real token store; write tests set `FAMILIAR_DATA_DIR` to a tmp dir and `FAMILIAR_ENABLE_WRITES`. `make_jwt(claims)` builds fake access tokens. diff --git a/README.md b/README.md index e05c00a..e64b1c7 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,23 @@ To opt out and block all writes globally: uv run familiar writes disable # turn writes off (uv run familiar writes enable to re-allow) ``` +### Run it publicly (HTTP over Docker) + +By default familiar talks **stdio** to a local client. It can also serve +**streamable HTTP** for reaching it from your home lab over HTTPS — opt-in, +behind a TLS reverse proxy, gated by a bearer token: + +```bash +cp .env.example .env # set FAMILIAR_HTTP_AUTH_TOKEN + FAMILIAR_DOMAIN +docker compose up -d --build # Traefik/Caddy terminates TLS on the `proxy` network +``` + +Secure by default: it binds loopback unless told otherwise, **refuses to start** +on a public interface without a bearer token, and ships with writes off. Log in +once on a machine with a browser (`familiar login`) and mount that token store +into the container. Full walkthrough — token, TLS, client wiring — in +[docs/deployment.md](docs/deployment.md). + ## Roadmap Built in phases. Each phase is independently useful, so the project stays shippable. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01ed8c2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + familiar: + build: + context: . + dockerfile: docker/Dockerfile + image: familiar:latest + container_name: familiar + restart: unless-stopped + env_file: + - .env + environment: + FAMILIAR_TRANSPORT: http + FAMILIAR_HTTP_HOST: 0.0.0.0 + FAMILIAR_HTTP_PORT: "8000" + FAMILIAR_HTTP_PATH: ${FAMILIAR_HTTP_PATH:-/mcp} + FAMILIAR_DATA_DIR: /data + # Bearer token that clients must send as `Authorization: Bearer `. + # REQUIRED here — binding 0.0.0.0 without it makes the server refuse to start. + FAMILIAR_HTTP_AUTH_TOKEN: ${FAMILIAR_HTTP_AUTH_TOKEN:?set FAMILIAR_HTTP_AUTH_TOKEN in .env} + # Public instance: keep writes off unless you consciously opt in. + 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` once on a machine with a browser, + # 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. + expose: + - "8000" + 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:8000/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. + 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: "8000" + +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..2711c66 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,58 @@ +# 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, wheel installed into a clean environment. +# --------------------------------------------------------------------------- # +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + # HTTP transport, reachable from the proxy network, token-gated. + FAMILIAR_TRANSPORT=http \ + FAMILIAR_HTTP_HOST=0.0.0.0 \ + FAMILIAR_HTTP_PORT=8000 \ + FAMILIAR_HTTP_PATH=/mcp \ + # Token store + audit log + write toggle live here — mount a persistent volume. + FAMILIAR_DATA_DIR=/data \ + # Public instance: writes off by default (smaller blast radius if the token leaks). + FAMILIAR_ENABLE_WRITES=false + +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 8000 +VOLUME ["/data"] + +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','8000')}/healthz\", timeout=5).raise_for_status()" || exit 1 + +CMD ["familiar", "serve"] diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..1fbe83e --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,132 @@ +# Deployment + +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 the top-level [README](../README.md). +2. **Public (HTTP)** — opt-in. Serve streamable HTTP from Docker in your home + lab, behind a TLS reverse proxy, gated by a bearer token. + +This page covers (2). + +## Secure defaults + +- **stdio unless told otherwise.** `FAMILIAR_TRANSPORT=http` switches transports. +- **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.** Binding a non-loopback interface without + `FAMILIAR_HTTP_AUTH_TOKEN` refuses to start (override only with the explicit + `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch). +- **Writes off** on the public image (`FAMILIAR_ENABLE_WRITES=false`) — smaller + blast radius if the token ever leaks. Local runs keep writes on. +- **Token never in the URL.** Clients authenticate with an + `Authorization: Bearer ` header only. + +## Environment variables + +| Variable | Default | Meaning | +|----------|---------|---------| +| `FAMILIAR_TRANSPORT` | `stdio` | `stdio` or `http`. | +| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address (image sets `0.0.0.0`). | +| `FAMILIAR_HTTP_PORT` | `8000` | Bind port. | +| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. | +| `FAMILIAR_HTTP_AUTH_TOKEN` | *(unset)* | Bearer token. **Required** for any non-loopback bind. | +| `FAMILIAR_ALLOW_INSECURE_BIND` | `false` | Allow a public bind without a token (don't). | +| `FAMILIAR_DATA_DIR` | per-user | Where `token.enc` + `key` + `audit.log` + write toggle live. Image sets `/data`. | +| `FAMILIAR_ENABLE_WRITES` | on | `false` disables all guarded writes. | + +`/healthz` is an unauthenticated liveness probe (used by the Docker healthcheck); +every MCP call under `FAMILIAR_HTTP_PATH` requires the bearer token. + +## One-time login (why it happens on the host) + +EVE SSO login is a browser PKCE flow with a `localhost` callback — it can't run +headless in a container. So authenticate **once on a machine with a browser**, +then hand the resulting token store to the container. The container only ever +*refreshes* tokens; it never opens a browser. + +```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\ +``` + +The `/data` volume is mounted read-write so token refresh (which rotates the +refresh token) and the audit log can persist. + +## Run with Docker Compose + +```bash +cp .env.example .env +# edit .env: set FAMILIAR_HTTP_AUTH_TOKEN (strong!), FAMILIAR_DOMAIN, cert resolver +python -c "import secrets; print(secrets.token_urlsafe(32))" # generate a token + +docker network create proxy # once, if your reverse proxy doesn't own it yet +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, `read_only` rootfs + tmpfs `/tmp`, `no-new-privileges`, + `cap_drop: ALL`. +- Ships Traefik router labels for TLS (Let's Encrypt) — enable/disable to taste. +- Healthchecks `GET /healthz`. + +### TLS: Traefik (labels, included) + +The compose file already carries Traefik labels. Set in `.env`: + +``` +FAMILIAR_DOMAIN=familiar.yourdomain.tld +TRAEFIK_CERTRESOLVER=letsencrypt +``` + +Traefik terminates HTTPS and forwards to the container's port 8000 on the +`proxy` network. Your public URL is then `https://familiar.yourdomain.tld/mcp`. + +### TLS: Caddy (alternative) + +If your home lab runs Caddy instead, drop the Traefik labels and add to your +`Caddyfile` (Caddy must share the `proxy` network / reach the container): + +``` +familiar.yourdomain.tld { + reverse_proxy familiar:8000 +} +``` + +Caddy fetches and renews the certificate automatically. + +## Connect a client + +Point an MCP client at the HTTPS endpoint with the bearer token: + +```bash +claude mcp add --transport http familiar https://familiar.yourdomain.tld/mcp \ + --header "Authorization: Bearer " +``` + +Claude Desktop / other clients: configure an HTTP (streamable) MCP server with +URL `https://familiar.yourdomain.tld/mcp` and the same `Authorization` header. + +## Operational notes + +- **Rotate the token** by changing `FAMILIAR_HTTP_AUTH_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 the `/data/audit.log` + write trail. +- **Re-login** (host) whenever you change requested scopes, then refresh the + `/data` token store. diff --git a/src/familiar/__main__.py b/src/familiar/__main__.py index cd3158a..5671e45 100644 --- a/src/familiar/__main__.py +++ b/src/familiar/__main__.py @@ -4,6 +4,8 @@ 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 (stdio, or HTTP via FAMILIAR_TRANSPORT) + familiar serve # same as above (explicit; handy as a Docker CMD) familiar login # add a character via EVE SSO (run once per character) familiar status # list logged-in characters familiar logout # forget ALL characters @@ -112,10 +114,19 @@ def main() -> None: print(__doc__) raise SystemExit(0) - # Default: run the MCP server over stdio. - from .server import mcp + # Default (and the explicit `serve` command): run the MCP server over the + # transport selected by the environment (stdio by default; HTTP in Docker). + if command not in (None, "serve"): + print(__doc__) + raise SystemExit(2) - mcp.run() + from . import serve + + try: + serve.run() + except serve.InsecureBind as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc if __name__ == "__main__": diff --git a/src/familiar/config.py b/src/familiar/config.py index 6d02dc1..121b152 100644 --- a/src/familiar/config.py +++ b/src/familiar/config.py @@ -7,8 +7,19 @@ match the registered EVE application) can be changed without code edits. from __future__ import annotations import os +from dataclasses import dataclass from pathlib import Path +_TRUE = {"1", "true", "yes", "on"} + + +def _env_bool(name: str, default: bool = False) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in _TRUE + + # EVE SSO endpoints. SSO_BASE = os.environ.get("FAMILIAR_SSO_BASE", "https://login.eveonline.com") AUTHORIZE_URL = f"{SSO_BASE}/v2/oauth/authorize" @@ -112,6 +123,45 @@ WRITE_SCOPES = [ SCOPES = os.environ.get("FAMILIAR_SCOPES", " ".join(READ_SCOPES + WRITE_SCOPES)).split() +# --------------------------------------------------------------------------- # +# Transport / HTTP serving (opt-in; stdio stays the default for local use) +# --------------------------------------------------------------------------- # +# The server runs over stdio by default (how Claude Desktop / Code launch it). +# Set FAMILIAR_TRANSPORT=http to serve streamable HTTP instead — meant to be run +# in Docker behind a TLS reverse proxy and gated by a bearer token. All settings +# are env-overridable so nothing here is baked into the image. +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +@dataclass(frozen=True) +class HttpSettings: + """Resolved transport settings for one server run (built from the env).""" + + transport: str # "stdio" | "http" + host: str + port: int + path: str + auth_token: str | None + allow_insecure_bind: bool + + @property + def is_loopback(self) -> bool: + return self.host in LOOPBACK_HOSTS + + +def http_settings() -> HttpSettings: + """Read transport settings from the environment (no validation, no I/O).""" + token = os.environ.get("FAMILIAR_HTTP_AUTH_TOKEN") + return HttpSettings( + transport=os.environ.get("FAMILIAR_TRANSPORT", "stdio").strip().lower(), + host=os.environ.get("FAMILIAR_HTTP_HOST", "127.0.0.1").strip(), + port=int(os.environ.get("FAMILIAR_HTTP_PORT", "8000")), + path=os.environ.get("FAMILIAR_HTTP_PATH", "/mcp"), + auth_token=token.strip() if token else None, + allow_insecure_bind=_env_bool("FAMILIAR_ALLOW_INSECURE_BIND"), + ) + + 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/serve.py b/src/familiar/serve.py new file mode 100644 index 0000000..e3842af --- /dev/null +++ b/src/familiar/serve.py @@ -0,0 +1,56 @@ +"""Transport selection for the MCP server. + +``run()`` honours the ``FAMILIAR_TRANSPORT`` environment variable: + +* ``stdio`` (default) — how Claude Desktop / Claude Code / Cowork launch it. +* ``http`` — streamable HTTP, meant to run in Docker behind a TLS reverse proxy + and gated by a bearer token (``FAMILIAR_HTTP_AUTH_TOKEN``). + +The HTTP path **fails closed**: binding anywhere other than loopback without a +bearer token (and without an explicit ``FAMILIAR_ALLOW_INSECURE_BIND=true`` +escape hatch) refuses to start rather than exposing an unauthenticated account +window to the network. +""" + +from __future__ import annotations + +from . import config + + +class InsecureBind(RuntimeError): + """A public HTTP bind was requested without a bearer token.""" + + +def validate(settings: config.HttpSettings) -> None: + """Raise if the requested HTTP settings would expose an unauthenticated server.""" + if settings.transport != "http": + return + if settings.auth_token or settings.is_loopback or settings.allow_insecure_bind: + return + raise InsecureBind( + f"Refusing to serve HTTP on {settings.host}:{settings.port} without a bearer " + "token. Set FAMILIAR_HTTP_AUTH_TOKEN (recommended, behind a TLS reverse " + "proxy), bind to loopback (FAMILIAR_HTTP_HOST=127.0.0.1), or set " + "FAMILIAR_ALLOW_INSECURE_BIND=true if you really mean it." + ) + + +def run() -> None: + """Run the server over the transport selected by the environment.""" + from .server import mcp + + settings = config.http_settings() + if settings.transport == "stdio": + mcp.run() + return + if settings.transport != "http": + raise SystemExit( + f"Unknown FAMILIAR_TRANSPORT={settings.transport!r} (expected 'stdio' or 'http')." + ) + validate(settings) + mcp.run( + transport="http", + host=settings.host, + port=settings.port, + path=settings.path, + ) diff --git a/src/familiar/server.py b/src/familiar/server.py index 18a1b72..563ce14 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -10,11 +10,14 @@ from __future__ import annotations import datetime as _dt from fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse from . import ( __version__, account, comms, + config, contacts, corp, corp_economy, @@ -34,6 +37,22 @@ from . import ( writes, ) + +def build_http_auth(token: str | None): + """Return a FastMCP bearer-token verifier, or None when no token is set. + + Only exercised by the HTTP transport (stdio has no auth layer), so it is + always safe to attach: when ``FAMILIAR_HTTP_AUTH_TOKEN`` is unset the server + is unauthenticated, which is fine for a loopback bind but refused for a + public bind by :mod:`familiar.serve`. + """ + if not token: + return None + from fastmcp.server.auth.providers.jwt import StaticTokenVerifier + + return StaticTokenVerifier({token: {"client_id": "familiar", "scopes": []}}) + + mcp: FastMCP = FastMCP( name="familiar", instructions=( @@ -43,9 +62,16 @@ mcp: FastMCP = FastMCP( "prices, the static universe (items, systems, stations), and — once the " "user has authenticated — their assets, skills, wallet, and location." ), + auth=build_http_auth(config.http_settings().auth_token), ) +@mcp.custom_route("/healthz", methods=["GET"]) +async def healthz(_request: Request) -> JSONResponse: + """Unauthenticated liveness probe for Docker / reverse-proxy healthchecks.""" + 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. diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..a020d2d --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,162 @@ +"""Tests for transport selection, HTTP settings, and the bearer-auth gate.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from familiar import config, serve, server + +_HTTP_ENV = [ + "FAMILIAR_TRANSPORT", + "FAMILIAR_HTTP_HOST", + "FAMILIAR_HTTP_PORT", + "FAMILIAR_HTTP_PATH", + "FAMILIAR_HTTP_AUTH_TOKEN", + "FAMILIAR_ALLOW_INSECURE_BIND", +] + + +@pytest.fixture +def clean_env(monkeypatch): + """Strip all transport-related env vars so defaults are exercised.""" + for name in _HTTP_ENV: + monkeypatch.delenv(name, raising=False) + return monkeypatch + + +# --------------------------------------------------------------------------- # +# config.http_settings +# --------------------------------------------------------------------------- # +def test_http_settings_defaults(clean_env): + s = config.http_settings() + assert s.transport == "stdio" + assert s.host == "127.0.0.1" + assert s.port == 8000 + assert s.path == "/mcp" + assert s.auth_token is None + assert s.allow_insecure_bind is False + assert s.is_loopback is True + + +def test_http_settings_overrides(clean_env): + clean_env.setenv("FAMILIAR_TRANSPORT", "HTTP") # case-insensitive + clean_env.setenv("FAMILIAR_HTTP_HOST", "0.0.0.0") + clean_env.setenv("FAMILIAR_HTTP_PORT", "9001") + clean_env.setenv("FAMILIAR_HTTP_PATH", "/eve") + clean_env.setenv("FAMILIAR_HTTP_AUTH_TOKEN", " secret ") + clean_env.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes") + s = config.http_settings() + assert s.transport == "http" + assert s.host == "0.0.0.0" + assert s.port == 9001 + assert s.path == "/eve" + assert s.auth_token == "secret" # trimmed + assert s.allow_insecure_bind is True + assert s.is_loopback is False + + +# --------------------------------------------------------------------------- # +# serve.validate — fail-closed on public binds +# --------------------------------------------------------------------------- # +def _settings(**kw) -> config.HttpSettings: + base = dict( + transport="http", + host="0.0.0.0", + port=8000, + path="/mcp", + auth_token=None, + allow_insecure_bind=False, + ) + base.update(kw) + return config.HttpSettings(**base) + + +def test_validate_stdio_is_noop(): + serve.validate(_settings(transport="stdio", host="0.0.0.0")) # no raise + + +def test_validate_loopback_needs_no_token(): + serve.validate(_settings(host="127.0.0.1")) # no raise + + +def test_validate_public_without_token_raises(): + with pytest.raises(serve.InsecureBind): + serve.validate(_settings(host="0.0.0.0")) + + +def test_validate_public_with_token_ok(): + serve.validate(_settings(host="0.0.0.0", auth_token="secret")) # no raise + + +def test_validate_public_with_insecure_optout_ok(): + serve.validate(_settings(host="0.0.0.0", allow_insecure_bind=True)) # no raise + + +# --------------------------------------------------------------------------- # +# server.build_http_auth — bearer token verifier +# --------------------------------------------------------------------------- # +def test_build_http_auth_none_without_token(): + assert server.build_http_auth(None) is None + assert server.build_http_auth("") is None + + +def test_build_http_auth_verifies_matching_token(): + verifier = server.build_http_auth("s3cret") + assert verifier is not None + good = asyncio.run(verifier.verify_token("s3cret")) + bad = asyncio.run(verifier.verify_token("wrong")) + assert good is not None + assert bad is None + + +# --------------------------------------------------------------------------- # +# serve.run — transport dispatch +# --------------------------------------------------------------------------- # +def test_run_stdio(clean_env, monkeypatch): + calls = {} + monkeypatch.setattr(server.mcp, "run", lambda *a, **k: calls.setdefault("args", (a, k))) + serve.run() + assert calls["args"] == ((), {}) # plain stdio run + + +def test_run_http_passes_transport_kwargs(clean_env, monkeypatch): + clean_env.setenv("FAMILIAR_TRANSPORT", "http") + clean_env.setenv("FAMILIAR_HTTP_AUTH_TOKEN", "secret") + clean_env.setenv("FAMILIAR_HTTP_PORT", "9100") + captured = {} + monkeypatch.setattr(server.mcp, "run", lambda *a, **k: captured.update(k)) + serve.run() + assert captured["transport"] == "http" + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 9100 + assert captured["path"] == "/mcp" + + +def test_run_http_public_without_token_refuses(clean_env, monkeypatch): + clean_env.setenv("FAMILIAR_TRANSPORT", "http") + clean_env.setenv("FAMILIAR_HTTP_HOST", "0.0.0.0") + monkeypatch.setattr(server.mcp, "run", lambda *a, **k: pytest.fail("must not run")) + with pytest.raises(serve.InsecureBind): + serve.run() + + +def test_run_unknown_transport_exits(clean_env, monkeypatch): + clean_env.setenv("FAMILIAR_TRANSPORT", "carrier-pigeon") + monkeypatch.setattr(server.mcp, "run", lambda *a, **k: pytest.fail("must not run")) + with pytest.raises(SystemExit): + serve.run() + + +# --------------------------------------------------------------------------- # +# /healthz custom route +# --------------------------------------------------------------------------- # +def test_healthz_route(): + resp = asyncio.run(server.healthz(None)) + assert resp.status_code == 200 + import json + + body = json.loads(bytes(resp.body)) + assert body["status"] == "ok" + assert body["server"] == "familiar"