HTTP transport + hardened Docker image, published to Gitea (dev + main) #62
No files matched your search
@@ -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
|
||||
@@ -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 <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
|
||||
|
||||
# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ #
|
||||
FAMILIAR_DOMAIN=familiar.example.com
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <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
|
||||
@@ -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"]
|
||||
@@ -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 <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.
|
||||
|
||||
## 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 <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.
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user