feat(serve): HTTP transport + hardened Docker image for secure public serving
Add an opt-in streamable-HTTP transport alongside the default stdio, so familiar can be reached from a home lab over HTTPS behind a TLS reverse proxy. - config.http_settings(): env-driven transport/host/port/path/token settings. - serve.run(): dispatch stdio vs http; fail closed on a non-loopback bind without a bearer token (FAMILIAR_ALLOW_INSECURE_BIND escape hatch). - server.build_http_auth(): FastMCP StaticTokenVerifier bearer gate; add an unauthenticated /healthz route for container/proxy probes. - `familiar serve` CLI alias; clean exit on InsecureBind. - docker/Dockerfile (multi-stage, non-root) + hardened docker-compose.yml (read-only rootfs, cap_drop ALL, no-new-privileges, Traefik TLS labels), .dockerignore, .env.example. Token store mounts at /data; writes off by default. - .gitea/workflows/docker.yml: build + smoke-test, opt-in image publish. - docs/deployment.md + README/CLAUDE.md notes; tests for settings, fail-closed validation, and the bearer verifier.
This commit is contained in:
13 files changed
+727
-4
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 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
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__":
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
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.
|
||||||
|
|||||||
@@ -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