HTTP transport + hardened Docker image, published to Gitea (dev + main) #62

Closed
Latte wants to merge 2 commits from feat/http-docker into dev
pull from: feat/http-docker
13 changed files with 800 additions and 4 deletions

No files matched your search

+36
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
# 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 <token>`.
# 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
# --- Image (published to Gitea by .gitea/workflows/docker.yml) -------------- #
# Prod channel: :latest or :main. Dev channel: :dev. Or pin a :sha-xxxxxxxx tag.
FAMILIAR_IMAGE=git.hiddenden.cafe/hiddenden/familiar:latest
# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ #
FAMILIAR_DOMAIN=familiar.example.com
TRAEFIK_CERTRESOLVER=letsencrypt
+115
View File
@@ -0,0 +1,115 @@
name: docker
on:
# Build + smoke-test on every branch push; registry push is gated per-step to main/dev.
push:
branches:
- '**'
pull_request:
branches:
- main
- dev
jobs:
# ---------------------------------------------------------------------------
# 1. Lint: ruff check + format.
# ---------------------------------------------------------------------------
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: Run lint
run: |
uv run ruff check .
uv run ruff format --check .
# ---------------------------------------------------------------------------
# 2. Test: pytest with coverage gate (fails under 80%).
# ---------------------------------------------------------------------------
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
run: uv run pytest
# ---------------------------------------------------------------------------
# 3. Build the Docker image, smoke-test it, push to Gitea (push events to
# main/dev only), then clean up so nothing lingers on the self-hosted
# runner.
# ---------------------------------------------------------------------------
docker:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- name: Checkout
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
# Production: stable :latest + :main
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
else
# dev (and any other branch): tag with the branch name
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: |
docker run --rm --entrypoint python "${{ steps.meta.outputs.sha_tag }}" \
-c "import familiar"
echo "Image imports cleanly."
- 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, stored as the REGISTRY_TOKEN secret.
# The auto-provided GITEA_TOKEN lacks package-write permission on
# this instance, so we use a dedicated token 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 exactly what this run created, even on failure.
# Scoped on purpose: if the runner shares the host Docker daemon, a global
# prune would also wipe other homelab services. We never create volumes
# here, so only dangling images + build cache are swept.
- name: Cleanup
if: always()
shell: bash
run: |
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
docker image prune -f || true
docker builder prune -f || true
+4 -1
View File
@@ -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 pytest tests/test_market.py::test_get_market_price # single test
uv run ruff format . # format uv run ruff format . # format
uv run ruff check . # lint (run both before committing; CI enforces them) uv run ruff check . # lint (run both before committing; CI enforces them)
uv run familiar # run the MCP server over stdio (how Claude launches it) uv run familiar # run the MCP server (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 login # browser EVE SSO login (adds a character; run once per character)
uv run familiar status # list logged-in characters uv run familiar status # list logged-in characters
uv run familiar writes disable # block write actions (default: enabled) uv run familiar writes disable # block write actions (default: enabled)
@@ -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. **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 ## 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. 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.
+17
View File
@@ -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) 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 ## Roadmap
Built in phases. Each phase is independently useful, so the project stays shippable. Built in phases. Each phase is independently useful, so the project stays shippable.
+71
View File
@@ -0,0 +1,71 @@
services:
familiar:
# Pull the image published to Gitea by .gitea/workflows/docker.yml.
# Override the tag via .env: FAMILIAR_IMAGE (:latest / :main for prod, :dev
# for the dev channel). `docker compose build` still rebuilds it locally.
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_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 <token>`.
# 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
+58
View File
@@ -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"]
+154
View File
@@ -0,0 +1,154 @@
# 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 <token>` 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.
## Published images (Gitea)
`.gitea/workflows/docker.yml` builds, smoke-tests, and pushes the image to the
Gitea container registry 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` (the auto `GITEA_TOKEN` lacks package-write here). Pick the
channel with `FAMILIAR_IMAGE` in `.env` (`:latest`/`:main` for prod, `:dev`).
## Run with Docker Compose
```bash
cp .env.example .env
# edit .env: FAMILIAR_HTTP_AUTH_TOKEN (strong!), FAMILIAR_DOMAIN, cert resolver,
# FAMILIAR_IMAGE (:latest for prod, :dev for the dev channel)
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
# Pull the published image (prod):
docker login git.hiddenden.cafe
docker compose pull && docker compose up -d
# ...or build it 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, `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 <your-token>"
```
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.
+14 -3
View File
@@ -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 Claude Code / Cowork launch it). The auth subcommands are meant to be run by
hand in a terminal: hand in a terminal:
familiar # run the MCP server (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 login # add a character via EVE SSO (run once per character)
familiar status # list logged-in characters familiar status # list logged-in characters
familiar logout # forget ALL characters familiar logout # forget ALL characters
@@ -112,10 +114,19 @@ def main() -> None:
print(__doc__) print(__doc__)
raise SystemExit(0) raise SystemExit(0)
# Default: run the MCP server over stdio. # Default (and the explicit `serve` command): run the MCP server over the
from .server import mcp # 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__": if __name__ == "__main__":
+50
View File
@@ -7,8 +7,19 @@ match the registered EVE application) can be changed without code edits.
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass
from pathlib import Path 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. # EVE SSO endpoints.
SSO_BASE = os.environ.get("FAMILIAR_SSO_BASE", "https://login.eveonline.com") SSO_BASE = os.environ.get("FAMILIAR_SSO_BASE", "https://login.eveonline.com")
AUTHORIZE_URL = f"{SSO_BASE}/v2/oauth/authorize" 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() 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: def data_dir() -> Path:
"""Where the encrypted token and key live. Created with restricted perms.""" """Where the encrypted token and key live. Created with restricted perms."""
override = os.environ.get("FAMILIAR_DATA_DIR") override = os.environ.get("FAMILIAR_DATA_DIR")
+56
View File
@@ -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,
)
+26
View File
@@ -10,11 +10,14 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
from fastmcp import FastMCP from fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
from . import ( from . import (
__version__, __version__,
account, account,
comms, comms,
config,
contacts, contacts,
corp, corp,
corp_economy, corp_economy,
@@ -34,6 +37,22 @@ from . import (
writes, 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( mcp: FastMCP = FastMCP(
name="familiar", name="familiar",
instructions=( instructions=(
@@ -43,9 +62,16 @@ mcp: FastMCP = FastMCP(
"prices, the static universe (items, systems, stations), and — once the " "prices, the static universe (items, systems, stations), and — once the "
"user has authenticated — their assets, skills, wallet, and location." "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 @mcp.tool
def ping() -> dict[str, str]: def ping() -> dict[str, str]:
"""Health check. Confirms the familiar server is alive and reports its version. """Health check. Confirms the familiar server is alive and reports its version.
+162
View File
@@ -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"