Merge pull request 'release: remote HTTP MCP — bearer + OAuth, Docker image, CI publish, per-client docs' (#65) from dev into main
CI / lint (push) Successful in 58s
docker / lint (push) Successful in 58s
CI / test (push) Successful in 1m7s
docker / test (push) Successful in 1m8s
docker / docker (push) Successful in 48s
CI / publish (push) Successful in 51s

Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
Latte committed 2026-07-05 15:50:52 +00:00
commit 3aeb4a80d9
26 files changed
+1546 -12

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# Keep the build context small and never ship local state or secrets.
.git
.gitea
.venv
**/__pycache__/
*.pyc
.pytest_cache
.ruff_cache
.coverage
htmlcov
dist
build
*.egg-info
# Local EVE token store / data dir — must never end up in an image layer.
data
*.enc
key
# Docs, tests, and dev tooling aren't needed at runtime.
tests
doc
docker-compose.yml
.env
.env.*
!.env.example
+46
View File
@@ -0,0 +1,46 @@
# familiar — remote HTTP / Docker deployment settings.
# Copy to .env and fill in. .env is gitignored; never commit real secrets.
# --- Image (published to Gitea by .gitea/workflows/docker.yml) -------------- #
# Prod channel: :latest or :main. Dev channel: :dev. Or pin :sha-<12>.
FAMILIAR_IMAGE=git.hiddenden.cafe/hiddenden/familiar:latest
# --- Bind + path ----------------------------------------------------------- #
# The image binds 0.0.0.0:8080 internally; only the reverse proxy reaches it.
# FAMILIAR_HTTP_PATH must match your proxy's location block.
FAMILIAR_HTTP_PATH=/mcp
# --- Auth mode ------------------------------------------------------------- #
# bearer (default) — a single static token (below). Works with every client.
# oauth — validate external-issuer JWTs; needed only for the Claude web
# connector (requires an external IdP with DCR). See doc/clients/claude-web.md.
FAMILIAR_AUTH_MODE=bearer
# --- Bearer auth (REQUIRED when FAMILIAR_AUTH_MODE=bearer) ------------------ #
# Token clients send as `Authorization: Bearer <token>`. Generate a strong one:
# openssl rand -hex 32
FAMILIAR_HTTP_TOKEN=
# Escape hatch: allow a public (0.0.0.0) bind with NO token. Leave false unless
# authentication is handled entirely upstream.
# FAMILIAR_ALLOW_INSECURE_BIND=false
# --- OAuth 2.1 resource server (only when FAMILIAR_AUTH_MODE=oauth) --------- #
# FAMILIAR_OAUTH_ISSUER=https://idp.example.com/application/o/familiar/
# FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com # this server's public URL
# FAMILIAR_OAUTH_AUDIENCE=https://familiar.example.com # defaults to RESOURCE
# FAMILIAR_OAUTH_JWKS_URI= # optional; else discovered from issuer
# --- Data / writes --------------------------------------------------------- #
# Host path holding the encrypted token store (token.enc + key), audit log, and
# write toggle. Produced by `familiar login` on a machine with a browser (or
# `familiar login --no-browser` on the host).
FAMILIAR_DATA_DIR_HOST=./data
# Writes off by default on a public instance. Set true to allow guarded writes.
FAMILIAR_ENABLE_WRITES=false
# --- Reverse proxy --------------------------------------------------------- #
# The container joins the external `proxy` network and exposes :8080; the proxy
# (Pangolin, or nginx/Caddy — see doc/deployment.md) terminates TLS and forwards
# to it. The public hostname and certs are configured in the proxy, not here.
+149
View File
@@ -0,0 +1,149 @@
name: docker
# Build + smoke-test the remote-HTTP (`familiar serve`) image on every push/PR;
# push to the Gitea container registry only on a real merge to main/dev.
# Runs alongside ci.yml (which publishes the Python wheel) — they don't overlap.
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
run: uv sync --group dev
- name: Ruff (lint + format check)
run: |
uv run ruff check .
uv run ruff format --check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
run: uv sync --group dev
- name: Run tests with coverage (fails under 80%)
run: uv run pytest
docker:
needs: [lint, test]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Compute image name & tags
id: meta
shell: bash
run: |
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
# Per-commit container name: the self-hosted runner shares the host
# Docker daemon, so a fixed name collides across concurrent/re-run jobs.
echo "container=familiar-smoke-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
else
echo "branch_tags=${IMAGE}:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Build image
shell: bash
run: docker build -f docker/Dockerfile -t "${{ steps.meta.outputs.sha_tag }}" .
- name: Smoke-test image
shell: bash
run: |
set -euo pipefail
IMG="${{ steps.meta.outputs.sha_tag }}"
CONTAINER="${{ steps.meta.outputs.container }}"
TOKEN="smoke-$(date +%s)"
# Remove any container left behind by a killed/re-run job before we
# reuse the name (shared host daemon → stale containers can survive).
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
# 1. Runs as a non-root user (uid 1000).
uid=$(docker run --rm --entrypoint id "$IMG" -u)
echo "container uid: $uid"
[ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; }
# 2. Boot the server and probe it from *inside* its own container via
# `docker exec`. On self-hosted runners the job step runs in a container
# that shares the host's Docker socket but sits on a different network,
# so it can reach neither a published host port (-p lands on the runner
# host, not this step's netns) nor the server container's bridge IP
# (different network → connect hangs). `docker exec` goes through the
# socket and runs the probe in the server's own netns, where
# 127.0.0.1:8080 always works regardless of runner topology. The image
# ships python + httpx, so no curl or extra pull is needed.
docker run -d --name "$CONTAINER" -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG"
# probe "<httpx call returning a Response>" -> prints the status code
probe() { docker exec "$CONTAINER" python -c "import httpx; print(($1).status_code)"; }
for i in $(seq 1 30); do
if docker exec "$CONTAINER" python -c \
"import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then
break
fi
sleep 1
done
echo "== /healthz (expect 200) =="
code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; }
echo "== MCP without token (expect 401) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})")
[ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; }
echo "== MCP with valid token (expect 200) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'ci','version':'1'}}})")
[ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs "$CONTAINER"; exit 1; }
echo "Image smoke-test passed."
- name: Log in to Gitea Container Registry
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')
uses: docker/login-action@v3
with:
registry: git.hiddenden.cafe
username: ${{ github.actor }}
# PAT with write:package scope (the auto GITEA_TOKEN lacks it here).
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Tag & push
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')
shell: bash
run: |
for tag in ${{ steps.meta.outputs.branch_tags }} ${{ steps.meta.outputs.sha_tag }}; do
docker tag "${{ steps.meta.outputs.sha_tag }}" "$tag"
docker push "$tag"
echo "Pushed $tag"
done
# Always runs — removes only what this run created (the self-hosted runner
# may share the host Docker daemon, so no global prune of other services).
- name: Cleanup
if: always()
shell: bash
run: |
docker rm -f ${{ steps.meta.outputs.container }} || true
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
docker image prune -f || true
docker builder prune -f || true
+14 -3
View File
@@ -17,9 +17,11 @@ uv run pytest tests/test_market.py::test_get_market_price # single test
uv run ruff format . # format uv run ruff 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 over stdio (how Claude launches it)
uv run familiar serve # run the streamable-HTTP transport (remote MCP; needs FAMILIAR_HTTP_TOKEN)
uv run familiar login # browser EVE SSO login (adds a character; run once per character) uv run familiar login # browser EVE SSO login (adds a character; run once per character)
uv run familiar login --no-browser # headless login: paste the callback URL on stdin
uv run familiar status # list logged-in characters uv run familiar status # list logged-in characters
uv run familiar writes disable # block write actions (default: enabled) uv run familiar writes disable # block write actions (default: enabled on stdio, disabled on HTTP)
``` ```
There is no separate build step; `uv build` produces the wheel (CI does this on merge). There is no separate build step; `uv build` produces the wheel (CI does this on merge).
@@ -28,13 +30,22 @@ There is no separate build step; `uv build` produces the wheel (CI does this on
**Server assembly.** [src/familiar/server.py](src/familiar/server.py) creates the single `FastMCP` instance and calls `register(mcp)` on each tool module. Every tool module (market, universe, account, industry, trade, structures, killmails, profile, corp, corp_economy, misc, comms, contacts, fittings, fleet, ui, planets, writes) follows the same shape: **module-level async functions** + a `TOOLS` list + `register(mcp)` that loops `mcp.tool(fn)`. Tools are plain importable functions (so tests call them directly); registration is separate. To add tools: new module → import + register in server.py. **Server assembly.** [src/familiar/server.py](src/familiar/server.py) creates the single `FastMCP` instance and calls `register(mcp)` on each tool module. Every tool module (market, universe, account, industry, trade, structures, killmails, profile, corp, corp_economy, misc, comms, contacts, fittings, fleet, ui, planets, writes) follows the same shape: **module-level async functions** + a `TOOLS` list + `register(mcp)` that loops `mcp.tool(fn)`. Tools are plain importable functions (so tests call them directly); registration is separate. To add tools: new module → import + register in server.py.
**Transport (stdio default, opt-in HTTP).** `familiar` (no args) runs `mcp.run()` over **stdio** (how Claude launches it) — local, no auth. `familiar serve` runs `server.serve_http()`: the FastMCP **streamable-HTTP** transport bound to loopback (`FAMILIAR_HTTP_HOST`/`FAMILIAR_HTTP_PORT`/`FAMILIAR_HTTP_PATH`, default `127.0.0.1:8080/mcp`) behind a TLS reverse proxy, for remote MCP clients (Mistral, Claude web/desktop/code, VS Code — see [doc/clients/](doc/clients/)). An unauthenticated `GET /healthz` liveness route (returns only status/server/version) is served for Docker/proxy probes. This transport-level auth is separate from the EVE-SSO/ESI auth in auth.py.
`configure_http(host=, token=, verifier=)` picks the auth provider from `FAMILIAR_AUTH_MODE`:
- **`bearer`** (default): a `StaticTokenVerifier` from `FAMILIAR_HTTP_TOKEN` (bad/missing bearer → 401 + `WWW-Authenticate: Bearer`). **Fail-closed** — no token → `SystemExit`, unless `FAMILIAR_ALLOW_INSECURE_BIND=true` explicitly permits a tokenless bind (the message distinguishes loopback vs a public interface via `config.is_loopback`).
- **`oauth`**: familiar is an OAuth 2.1 **resource server** only — a `JWTVerifier` (JWKS from `FAMILIAR_OAUTH_JWKS_URI` or discovered from `FAMILIAR_OAUTH_ISSUER`'s OIDC metadata, validating `issuer` + `audience`) wrapped in a `RemoteAuthProvider` that serves `/.well-known/oauth-protected-resource/<path>`. Config: `FAMILIAR_OAUTH_ISSUER`, `FAMILIAR_OAUTH_RESOURCE` (this server's public URL, required), `FAMILIAR_OAUTH_AUDIENCE` (defaults to resource). No DCR/authorization-server in familiar; needs an external IdP (Authentik/Keycloak) for the claude.ai web connector.
Either mode calls `writes.set_http_context(True)`, flipping the **write default to disabled** unless `FAMILIAR_ENABLE_WRITES` is explicitly truthy (only the default resolution changes; `writes.guard` is untouched).
**ESI access.** [esi.py](src/familiar/esi.py) is a thin async httpx wrapper (`client()` singleton, `get/post/put/delete`, `get_all_pages` for `X-Pages` pagination, Bearer `token=` for authed calls, uniform `EsiError`). Base URL and datasource are env-overridable. [zkill.py](src/familiar/zkill.py) mirrors it for zKillboard. Both expose `set_client()` so tests inject an `httpx.MockTransport`. **ESI access.** [esi.py](src/familiar/esi.py) is a thin async httpx wrapper (`client()` singleton, `get/post/put/delete`, `get_all_pages` for `X-Pages` pagination, Bearer `token=` for authed calls, uniform `EsiError`). Base URL and datasource are env-overridable. [zkill.py](src/familiar/zkill.py) mirrors it for zKillboard. Both expose `set_client()` so tests inject an `httpx.MockTransport`.
**Auth is multi-character.** [auth.py](src/familiar/auth.py) does EVE SSO PKCE (no client secret), stores a **dict of characters keyed by id** in one Fernet-encrypted file (`load_store`/`_save_character`/`list_characters`/`remove_character`), and `get_auth(character_id)` returns a valid access token, refreshing via the rotating refresh token when near expiry. The legacy single-character file is migrated on load. Config (client id, callback `localhost:8765`, the 73 read+write scopes split into `READ_SCOPES`/`WRITE_SCOPES`) lives in [config.py](src/familiar/config.py), all env-overridable. **Auth is multi-character.** [auth.py](src/familiar/auth.py) does EVE SSO PKCE (no client secret), stores a **dict of characters keyed by id** in one Fernet-encrypted file (`load_store`/`_save_character`/`list_characters`/`remove_character`), and `get_auth(character_id)` returns a valid access token, refreshing via the rotating refresh token when near expiry. The legacy single-character file is migrated on load. Config (client id, callback `localhost:8765`, the 73 read+write scopes split into `READ_SCOPES`/`WRITE_SCOPES`) lives in [config.py](src/familiar/config.py), all env-overridable.
**Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these. **Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these.
**Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **on by default** (opt out via `familiar writes disable`; toggle via `familiar writes enable`/`disable` or `FAMILIAR_ENABLE_WRITES`). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly. **Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **on by default over stdio** (opt out via `familiar writes disable`; toggle via `familiar writes enable`/`disable` or `FAMILIAR_ENABLE_WRITES`) but **off by default over HTTP** (see the transport note — `set_http_context` flips the default; `FAMILIAR_ENABLE_WRITES` still overrides). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly.
## Testing conventions ## Testing conventions
@@ -44,5 +55,5 @@ Tests use [tests/conftest.py](tests/conftest.py): the `mock_esi` fixture install
- Work happens on `dev`. Feature branches → PR → fast-forward merge into `dev` → delete branch. Cut a release by bumping the version in `pyproject.toml` / `src/familiar/__init__.py` and opening a `dev → main` fast-forward PR (publishes the clean version). - Work happens on `dev`. Feature branches → PR → fast-forward merge into `dev` → delete branch. Cut a release by bumping the version in `pyproject.toml` / `src/familiar/__init__.py` and opening a `dev → main` fast-forward PR (publishes the clean version).
- `main` and `dev` are protected: no direct pushes, **signed commits required** (the user's git auto-signs), merge via PR. - `main` and `dev` are protected: no direct pushes, **signed commits required** (the user's git auto-signs), merge via PR.
- CI (`.gitea/workflows/ci.yml`) runs lint + test on every PR/push; on a merge to main/dev it builds and publishes to the Gitea PyPI registry (auth via the `REGISTRY_TOKEN` secret; twine must NOT use `--skip-existing`). dev publishes a unique `.devN` pre-release. - Two workflows run lint + test on every PR/push. `.gitea/workflows/ci.yml` additionally builds + publishes the Python wheel to the Gitea PyPI registry on a merge to main/dev (auth via the `REGISTRY_TOKEN` secret; twine must NOT use `--skip-existing`; dev publishes a unique `.devN` pre-release). `.gitea/workflows/docker.yml` additionally builds + smoke-tests the Docker image (`docker/Dockerfile`, runs `familiar serve`; checks non-root, `/healthz` 200, MCP 401 without / 200 with a token) on every run and, on a merge to main/dev, pushes it to the Gitea container registry `git.hiddenden.cafe/hiddenden/familiar` (main → `:latest`/`:main`, dev → `:dev`, plus `:sha-<12>`) using the same `REGISTRY_TOKEN`. The two workflows are independent and must not interfere.
- **Do NOT attribute commits, merges, or PRs to Claude / Claude Code.** No `Co-Authored-By: Claude ...` trailer on commits and no "Generated with Claude Code" footer in PR descriptions. Keep messages plain and authored by the user. - **Do NOT attribute commits, merges, or PRs to Claude / Claude Code.** No `Co-Authored-By: Claude ...` trailer on commits and no "Generated with Claude Code" footer in PR descriptions. Keep messages plain and authored by the user.
+45
View File
@@ -77,6 +77,51 @@ 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)
``` ```
## Serve over HTTPS (remote MCP)
By default familiar talks over **stdio** — the local transport Claude launches. To reach it from a remote MCP client (for example a [chat.mistral.ai](https://chat.mistral.ai) custom connector), run the opt-in **streamable-HTTP** transport instead:
```bash
FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve
```
`familiar serve` binds `127.0.0.1:8080/mcp` and expects a TLS-terminating reverse proxy in front of it. It **refuses to start without `FAMILIAR_HTTP_TOKEN`** — an internet-reachable account window must never run unauthenticated. Requests without a valid `Authorization: Bearer <token>` get `401` with a `WWW-Authenticate: Bearer` header; a valid token lets the MCP handshake through. stdio (`familiar` with no args) is unchanged and never token-gated.
Environment variables:
| Variable | Default | Meaning |
| --- | --- | --- |
| `FAMILIAR_HTTP_TOKEN` | *(required)* | Bearer token the client must send. No default — serve refuses to start without it. |
| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address. Leave loopback and let the proxy face the internet. |
| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. |
| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. |
| `FAMILIAR_ENABLE_WRITES` | *(unset)* | In HTTP mode **writes default off**; set to `1`/`true` to opt in. |
**Writes are disabled by default over HTTP** — a network-reachable, LLM-driven endpoint should not send mail or set waypoints unless you explicitly opt in with `FAMILIAR_ENABLE_WRITES=1`. (Over stdio they stay on by default, as before.)
By default familiar authenticates with a static bearer token (`FAMILIAR_AUTH_MODE=bearer`). For clients that only speak OAuth (the claude.ai web connector), there's an opt-in `oauth` mode where familiar acts as an OAuth 2.1 resource server in front of your own IdP — see [claude-web](doc/clients/claude-web.md).
### Deploy with Docker
CI builds and publishes a hardened image on every merge to `dev`/`main` (`git.hiddenden.cafe/hiddenden/familiar`, tags `:dev`, `:main`/`:latest`). It runs `familiar serve` non-root on port `8080`, has a `/healthz` healthcheck, and keeps the encrypted token store under a `/data` volume. A [`docker-compose.yml`](docker-compose.yml) + [`.env.example`](.env.example) are included.
**Full guide — Docker, reverse proxy (nginx / Caddy / Traefik), TLS, token provisioning, and the env-var reference — is in [doc/deployment.md](doc/deployment.md).**
### Connect a client
Pick your client for exact steps:
| Client | Auth | Guide |
| --- | --- | --- |
| Claude Code | Bearer | [doc/clients/claude-code.md](doc/clients/claude-code.md) |
| VS Code (Copilot) | Bearer | [doc/clients/vscode.md](doc/clients/vscode.md) |
| Mistral Le Chat | Bearer | [doc/clients/mistral.md](doc/clients/mistral.md) |
| Claude Desktop | Bearer (via `mcp-remote`) | [doc/clients/claude-desktop.md](doc/clients/claude-desktop.md) |
| Claude Cowork | Bearer (via `mcp-remote`) | [doc/clients/claude-cowork.md](doc/clients/claude-cowork.md) |
| Claude web (claude.ai) | OAuth 2.1 (external IdP) | [doc/clients/claude-web.md](doc/clients/claude-web.md) |
Bearer works everywhere except the claude.ai web connector, which requires OAuth with Dynamic Client Registration. The quickest path for a Claude user who doesn't want to run an IdP is the **Desktop** app with a bearer token.
## Roadmap ## 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.
+2
View File
@@ -38,6 +38,8 @@ fittings, fleet, UI windows, PI) gated behind a safety choke point.
| Document | What's in it | | Document | What's in it |
| --- | --- | | --- | --- |
| [Getting Started](getting-started.md) | Install with `uv`, log in via EVE SSO, connect to Claude, first queries. | | [Getting Started](getting-started.md) | Install with `uv`, log in via EVE SSO, connect to Claude, first queries. |
| [Deployment](deployment.md) | Run familiar as a remote MCP server: Docker, reverse proxy + TLS, bearer/OAuth, env reference. |
| [Client setup](clients/) | Exact connection steps per client — Claude Code, VS Code, Desktop, Cowork, web, Mistral. |
| [Architecture](architecture.md) | How the server is assembled, the ESI/auth/charutil/writes layers, the module pattern. | | [Architecture](architecture.md) | How the server is assembled, the ESI/auth/charutil/writes layers, the module pattern. |
| [Authentication](authentication.md) | EVE SSO PKCE flow, the encrypted multi-character store, scopes, CLI commands. | | [Authentication](authentication.md) | EVE SSO PKCE flow, the encrypted multi-character store, scopes, CLI commands. |
| [Write Safety](write-safety.md) | The `writes.guard` choke point, dry-run previews, the audit log, enabling/disabling. | | [Write Safety](write-safety.md) | The `writes.guard` choke point, dry-run previews, the audit log, enabling/disabling. |
+28 -1
View File
@@ -40,6 +40,31 @@ Adding tools is mechanical: **write a new module, import it in `server.py`, and
> text predates the write actions and is historical framing; the real behaviour is read **and** > text predates the write actions and is historical framing; the real behaviour is read **and**
> guarded write (see [Write Safety](write-safety.md)). > guarded write (see [Write Safety](write-safety.md)).
## Transport — stdio (default) and HTTP (opt-in)
The same `mcp` instance is served over one of two transports; the tools and every layer below are
identical either way.
- **stdio (default).** `familiar` with no arguments calls `mcp.run()` — the local transport Claude
Desktop / Claude Code / Cowork launch over the process's stdin/stdout. No network, no auth layer:
the transport is local to the machine and trusted.
- **streamable-HTTP (opt-in).** `familiar serve` calls `server.serve_http()`, which runs the FastMCP
streamable-HTTP transport bound to loopback (`FAMILIAR_HTTP_HOST`/`PORT`/`PATH`, default
`127.0.0.1:8080/mcp`) behind a TLS-terminating reverse proxy. `configure_http()` attaches a FastMCP
`StaticTokenVerifier` built from `FAMILIAR_HTTP_TOKEN`, so a request must present a matching
`Authorization: Bearer <token>` — anything else is a `401` with a `WWW-Authenticate: Bearer` header.
Serving **refuses to start without a token** (a `SystemExit` from `configure_http`), so the endpoint
is never open to the internet unauthenticated.
The EVE-SSO/ESI auth in `auth.py` is unrelated to this transport-level bearer token: the bearer token
gates the *MCP client* (e.g. Mistral) reaching familiar, while `auth.py` gates familiar reaching ESI.
**Write default depends on the transport.** `configure_http()` calls `writes.set_http_context(True)`,
which flips the *default* write policy: over HTTP writes are **disabled** unless `FAMILIAR_ENABLE_WRITES`
is explicitly truthy, because a network-reachable, LLM-driven endpoint should not send mail or set
waypoints by accident. Over stdio the default is unchanged (writes on). This only changes the default
resolution — the `writes.guard` choke point and the per-call `confirm=true` requirement are untouched.
## The tool-module pattern ## The tool-module pattern
Every tool module — market, universe, account, industry, trade, structures, killmails, profile, corp, Every tool module — market, universe, account, industry, trade, structures, killmails, profile, corp,
@@ -91,7 +116,9 @@ when to call it, so docstrings are written for that audience.
**multi-character** encrypted store. Full details in [Authentication](authentication.md). The **multi-character** encrypted store. Full details in [Authentication](authentication.md). The
short version: short version:
- `interactive_login()` runs the browser flow once per character and persists a bundle. - `interactive_login()` runs the browser flow once per character and persists a bundle;
`manual_login()` is the headless variant (`familiar login --no-browser`) that prints the authorize
URL and reads the pasted callback URL from stdin — no local browser or callback listener needed.
- The store is a dict of characters keyed by id, in one Fernet-encrypted file; a legacy - The store is a dict of characters keyed by id, in one Fernet-encrypted file; a legacy
single-character file is migrated on load. single-character file is migrated on load.
- `get_auth(character_id)` returns a valid access token, transparently refreshing via the rotating - `get_auth(character_id)` returns a valid access token, transparently refreshing via the rotating
+18
View File
@@ -0,0 +1,18 @@
# Connect a client
Exact steps to point each MCP client at a familiar server deployed over HTTPS
(see [deployment](../deployment.md) first). Most clients use a **bearer token**
(`FAMILIAR_HTTP_TOKEN`); only the claude.ai web connector needs **OAuth**.
| Client | Auth | Guide |
| --- | --- | --- |
| Claude Code | Bearer | [claude-code.md](claude-code.md) |
| VS Code (Copilot) | Bearer | [vscode.md](vscode.md) |
| Mistral Le Chat | Bearer | [mistral.md](mistral.md) |
| Claude Desktop | Bearer (via `mcp-remote`) | [claude-desktop.md](claude-desktop.md) |
| Claude Cowork | Bearer (via `mcp-remote`) | [claude-cowork.md](claude-cowork.md) |
| Claude web (claude.ai) | OAuth 2.1 (external IdP) | [claude-web.md](claude-web.md) |
Bearer works everywhere except the claude.ai web connector, which requires
OAuth 2.1 with Dynamic Client Registration. If you're a Claude user who doesn't
want to run an IdP, use the **Desktop** app with a bearer token instead.
+28
View File
@@ -0,0 +1,28 @@
# Connect Claude Code
**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)).
Claude Code speaks HTTP MCP with custom headers directly. Add the server:
```bash
claude mcp add --transport http familiar https://familiar.yourdomain.tld/mcp \
--header "Authorization: Bearer <your-token>"
```
- `<your-token>` is your `FAMILIAR_HTTP_TOKEN`.
- Use the full URL including the `FAMILIAR_HTTP_PATH` (default `/mcp`).
Check it:
```bash
claude mcp list # familiar should show as connected
```
Then ask Claude Code to call `ping`, or "what's Tritanium worth in Jita?".
To scope it to the current project instead of your user config, add `--scope project` (writes a `.mcp.json` in the repo). Remove it with `claude mcp remove familiar`.
## Troubleshooting
- **401** — the token is wrong or missing; re-run `claude mcp add` with the correct `Authorization` header.
- **Hangs / times out** — your reverse proxy is likely buffering; set `proxy_buffering off;` and a long read timeout (see [deployment](../deployment.md#tls-reverse-proxy)).
+9
View File
@@ -0,0 +1,9 @@
# Connect Claude Cowork
**Auth:** bearer token, via a local bridge.
Cowork shares Claude Desktop's connector plumbing, so the setup is identical: bridge the remote HTTP server to a local stdio command with `mcp-remote` so the `Authorization: Bearer <token>` header gets forwarded.
Follow **[Connect Claude Desktop](claude-desktop.md)** — the same `claude_desktop_config.json` entry (the `npx mcp-remote … --header "Authorization: Bearer <token>"` block) applies. After editing, restart Cowork.
If you've configured it once for Desktop on the same machine, Cowork picks up the same config.
+38
View File
@@ -0,0 +1,38 @@
# Connect Claude Desktop
**Auth:** bearer token, via a local bridge. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)), plus Node.js (`npx`) on the machine running Claude Desktop.
Claude Desktop's **Connectors** UI can add a remote MCP URL but **cannot attach a bearer header**. So bridge the remote HTTP server to a local stdio command with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which forwards the `Authorization` header.
Edit `claude_desktop_config.json`:
```json
{
"mcpServers": {
"familiar": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://familiar.yourdomain.tld/mcp",
"--header",
"Authorization: Bearer <your-token>"
]
}
}
}
```
Config file location:
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux:** `~/.config/Claude/claude_desktop_config.json`
Restart Claude Desktop fully (quit, not just close the window). familiar appears in the tools/plug icon.
## Troubleshooting
- **Server fails to start** — ensure Node.js is installed and `npx` is on PATH; the first run downloads `mcp-remote`.
- **401** — fix the token in the `--header` argument, then restart.
- **Want native (no bridge)?** Only the OAuth path is header-free, and Desktop's connector is happiest with bearer via this bridge — keep `mcp-remote` unless you've set up [oauth mode](claude-web.md).
+39
View File
@@ -0,0 +1,39 @@
# Connect Claude web (claude.ai)
**Auth:** OAuth 2.1 only. **Prereq:** familiar in `oauth` mode behind HTTPS, plus an external identity provider.
The claude.ai custom-connector flow speaks **OAuth 2.1 with Dynamic Client Registration (DCR)** and does **not** accept a static bearer token. familiar itself is only a **resource server** — it validates JWTs and advertises where to authenticate, but it is **not** an authorization server and does no DCR. So Claude web needs an external IdP that supports DCR (e.g. **Authentik** or **Keycloak**) sitting in front.
> **Simpler alternative:** if you don't want to run an IdP, use the **Claude Desktop** app with a bearer token instead — see [Connect Claude Desktop](claude-desktop.md). Bearer is easier for every client except the claude.ai web connector.
## 1. Set up the IdP (external)
On your IdP, create an application/provider for familiar that:
- issues **JWT access tokens** signed with a key published at a **JWKS** endpoint,
- sets the token **audience** to familiar's public URL (the resource),
- supports **Dynamic Client Registration** so claude.ai can register itself.
Note the issuer URL and (optionally) the JWKS URL.
## 2. Configure familiar in oauth mode
```bash
FAMILIAR_AUTH_MODE=oauth
FAMILIAR_OAUTH_ISSUER=https://idp.yourdomain.tld/application/o/familiar/
FAMILIAR_OAUTH_RESOURCE=https://familiar.yourdomain.tld
# FAMILIAR_OAUTH_AUDIENCE defaults to the resource URL; override if your IdP differs.
# FAMILIAR_OAUTH_JWKS_URI is discovered from the issuer's OIDC metadata if unset.
```
No `FAMILIAR_HTTP_TOKEN` is needed in oauth mode. familiar serves the discovery document at `https://familiar.yourdomain.tld/.well-known/oauth-protected-resource/mcp`, and unauthenticated MCP calls return `401` with a `WWW-Authenticate: Bearer` header pointing at your issuer.
## 3. Add the connector
In claude.ai: **Settings → Connectors → Add custom connector**, URL `https://familiar.yourdomain.tld/mcp`. Claude discovers your IdP from the metadata, registers via DCR, and runs the OAuth login. Approve familiar's scopes and you're connected.
## Troubleshooting
- **"couldn't connect" immediately** — familiar isn't in `oauth` mode, or `FAMILIAR_OAUTH_RESOURCE`/`ISSUER` are unset; the metadata endpoint must return 200.
- **Login loops / invalid token** — audience mismatch: your IdP's token `aud` must equal `FAMILIAR_OAUTH_RESOURCE` (or your `FAMILIAR_OAUTH_AUDIENCE`).
- **DCR fails** — the IdP doesn't have Dynamic Client Registration enabled; claude.ai requires it.
+17
View File
@@ -0,0 +1,17 @@
# Connect Mistral Le Chat
**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)).
Mistral's [chat.mistral.ai](https://chat.mistral.ai) supports remote MCP servers with a bearer token natively.
1. Open **Intelligence → Connectors → Add custom connector**.
2. **URL:** `https://familiar.yourdomain.tld/mcp` (include the `FAMILIAR_HTTP_PATH`, default `/mcp`).
3. **Authentication:** choose **Bearer Token** and paste your `FAMILIAR_HTTP_TOKEN`.
4. Save, then enable the connector in a conversation.
Ask Le Chat to call `ping`, or "compare Plex prices across trade hubs".
## Troubleshooting
- **401 / connector won't enable** — wrong or missing token; re-check the Bearer Token value.
- **Timeouts** — reverse-proxy buffering; set `proxy_buffering off;` and a long read timeout (see [deployment](../deployment.md#tls-reverse-proxy)).
+38
View File
@@ -0,0 +1,38 @@
# Connect VS Code (Copilot / MCP)
**Auth:** bearer token. **Prereq:** familiar deployed over HTTPS (see [deployment](../deployment.md)).
VS Code's MCP support reads an **`http`** server entry with custom headers. Put it in **`.vscode/mcp.json`** (per-workspace) or your user `mcp.json`.
> ⚠️ Use **`.vscode/mcp.json`**, not a workspace-root **`.mcp.json`**. The workspace `.mcp.json` format ignores `headers`, so the bearer token never gets sent and you'll get a silent 401. This is a known VS Code quirk.
`.vscode/mcp.json`:
```json
{
"inputs": [
{
"id": "familiar_token",
"type": "promptString",
"description": "familiar bearer token",
"password": true
}
],
"servers": {
"familiar": {
"type": "http",
"url": "https://familiar.yourdomain.tld/mcp",
"headers": {
"Authorization": "Bearer ${input:familiar_token}"
}
}
}
}
```
On first use VS Code prompts for the token (masked, thanks to `password: true`) and reuses it. Start the server from the MCP view or the `MCP: List Servers` command, then use it from Copilot Chat's agent mode.
## Troubleshooting
- **Silent 401 / "no tools"** — you're almost certainly in a root `.mcp.json`; move the config to `.vscode/mcp.json` so `headers` is honored.
- **401 after entering a token** — wrong token; clear it via `MCP: Reset Cached Tokens` (or restart) and re-enter.
+135
View File
@@ -0,0 +1,135 @@
# Deployment — familiar as a remote MCP server
familiar runs two ways from the **same** code and token store:
1. **Local (stdio)** — the default. How Claude Desktop / Claude Code / Cowork launch it. Nothing here changes; see [getting-started](getting-started.md).
2. **Remote (HTTP)** — opt-in via `familiar serve`. Streamable HTTP behind a TLS reverse proxy, gated by a bearer token (or external OAuth). This page covers (2).
Once it's running, see [doc/clients/](clients/) for exact per-client setup.
## Secure defaults
- **stdio unless told otherwise.** `familiar serve` (not the bare `familiar`) starts HTTP.
- **Loopback bind by default** (`127.0.0.1`). The Docker image binds `0.0.0.0` so the reverse proxy can reach it.
- **Fail closed.** familiar refuses to start without authentication. In bearer mode that means no `FAMILIAR_HTTP_TOKEN` → it won't boot; a non-loopback bind without a token is called out explicitly. The only override is the explicit `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch.
- **Writes off over HTTP.** A network-reachable, LLM-driven endpoint keeps writes disabled unless you set `FAMILIAR_ENABLE_WRITES=true`. Local stdio keeps writes on.
- **Token never in the URL.** Clients authenticate with an `Authorization: Bearer <token>` header only.
## Authentication modes
`FAMILIAR_AUTH_MODE` picks how the HTTP transport authenticates:
- **`bearer`** (default) — one static token in `FAMILIAR_HTTP_TOKEN`. Simple, and every client below except Claude web supports it. Recommended.
- **`oauth`** — familiar runs as an OAuth 2.1 **resource server**: it validates incoming JWT access tokens against an external issuer's JWKS and serves `/.well-known/oauth-protected-resource/<path>` so OAuth-only clients can discover the authorization server. familiar is **not** an authorization server and does **no** Dynamic Client Registration — you must run an external IdP that does (Authentik, Keycloak, …). Only needed for the native **Claude web** connector. See [doc/clients/claude-web.md](clients/claude-web.md).
## Environment variables
| Variable | Default | Meaning |
| --- | --- | --- |
| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address (image sets `0.0.0.0`). |
| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. |
| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. |
| `FAMILIAR_AUTH_MODE` | `bearer` | `bearer` or `oauth`. |
| `FAMILIAR_HTTP_TOKEN` | *(unset)* | Bearer token. **Required** in bearer mode. |
| `FAMILIAR_ALLOW_INSECURE_BIND` | `false` | Allow a bind without a token (don't). |
| `FAMILIAR_OAUTH_ISSUER` | *(unset)* | Authorization-server URL (oauth mode). |
| `FAMILIAR_OAUTH_RESOURCE` | *(unset)* | This server's public URL, e.g. `https://familiar.example.com` (oauth mode). |
| `FAMILIAR_OAUTH_AUDIENCE` | = resource | Expected token audience (oauth mode). |
| `FAMILIAR_OAUTH_JWKS_URI` | *(discovered)* | JWKS URL; if unset, discovered from the issuer's OIDC metadata. |
| `FAMILIAR_DATA_DIR` | per-user | Where `token.enc` + `key` + `audit.log` + write toggle live. Image sets `/data`. |
| `FAMILIAR_ENABLE_WRITES` | on (stdio) / off (HTTP) | `true` allows guarded writes; over HTTP it's off unless set. |
`/healthz` is an unauthenticated liveness probe (used by the Docker healthcheck and reverse-proxy probes). It returns only `{"status":"ok","server":"familiar","version":"…"}` — no account data. Every MCP call under `FAMILIAR_HTTP_PATH` requires authentication.
## One-time login (why it happens on the host)
EVE SSO login is a browser PKCE flow with a `localhost` callback — awkward inside a container. Authenticate **once on a machine with a browser**, then hand the resulting token store to the container. The container only ever *refreshes* tokens.
```bash
uv run familiar login # opens the browser, writes token.enc + key
uv run familiar status # confirm the character(s)
```
Then make that data dir available to the container. Point `FAMILIAR_DATA_DIR_HOST` at it, or copy `token.enc` + `key` into `./data`:
```bash
mkdir -p ./data
# Linux/macOS host default:
cp ~/.local/share/familiar/{token.enc,key} ./data/
# Windows host default:
# copy %LOCALAPPDATA%\familiar\token.enc .\data\ ; copy %LOCALAPPDATA%\familiar\key .\data\
```
**Headless host?** Use `uv run familiar login --no-browser`: it prints the SSO URL, you authorize in any browser, and paste the redirected callback URL back on stdin. The `/data` volume is mounted read-write so token refresh (which rotates the refresh token) and the audit log persist.
## Published images (Gitea)
`.gitea/workflows/docker.yml` builds, smoke-tests (non-root, `/healthz` 200, MCP 401 without / 200 with a token), and pushes on every push to `main`/`dev`:
| Branch | Tags |
| --- | --- |
| `main` | `git.hiddenden.cafe/hiddenden/familiar:latest`, `:main`, `:sha-<12>` |
| `dev` | `git.hiddenden.cafe/hiddenden/familiar:dev`, `:sha-<12>` |
Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret `REGISTRY_TOKEN`. Pick the channel with `FAMILIAR_IMAGE` in `.env`.
## Run with Docker Compose
```bash
cp .env.example .env
# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32),
# FAMILIAR_IMAGE (:latest prod / :dev dev channel)
docker network create proxy # once, if your reverse proxy doesn't own it yet
docker login git.hiddenden.cafe
docker compose pull && docker compose up -d
# ...or build locally instead: docker compose up -d --build
docker compose logs -f familiar
```
The compose service joins the external `proxy` network (not published to the host), runs non-root with a `read_only` rootfs + tmpfs `/tmp`, `no-new-privileges` and `cap_drop: ALL`, and healthchecks `GET /healthz`.
## TLS reverse proxy
familiar terminates no TLS itself — a proxy does. Streamable HTTP holds a long-lived response open, so **disable buffering** and use a **generous read timeout** on the MCP path.
### nginx
```nginx
location /mcp {
proxy_pass http://127.0.0.1:8080/mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # stream events straight through
proxy_read_timeout 3600s; # don't cut long-lived MCP sessions
}
```
(If you run the container on the `proxy` network instead of publishing to the host, point `proxy_pass` at `http://familiar:8080/mcp`.)
### Caddy
```
familiar.yourdomain.tld {
reverse_proxy familiar:8080
}
```
Caddy fetches and renews the certificate automatically and streams by default.
### Pangolin
The compose service exposes `:8080` on the shared external `proxy` network and carries no proxy-specific labels. In Pangolin, add a resource for `familiar` that forwards to `http://familiar:8080` (target the container's `/mcp` path), and let Pangolin terminate HTTPS. Make sure Pangolin's own container is attached to the same `proxy` network so it can reach the service by name. Your public URL is then `https://familiar.yourdomain.tld/mcp`.
Because streamable HTTP holds a long-lived response open, raise the read/response timeout on the familiar resource and disable response buffering if the option is exposed (same reasoning as the nginx `proxy_read_timeout` / `proxy_buffering off` above).
## Operational notes
- **Rotate the token** by changing `FAMILIAR_HTTP_TOKEN` and restarting; update every client header.
- **Restrict inbound** at the proxy/firewall to the clients you expect.
- **Watch** `docker compose logs` for `401` auth failures and `/data/audit.log` for the write trail.
- **Re-login** (host) whenever you change requested scopes, then refresh the `/data` token store.
+80 -1
View File
@@ -99,7 +99,86 @@ uv run familiar writes # show current state
Full details in [Write Safety](write-safety.md). Full details in [Write Safety](write-safety.md).
## 7. Verify everything works ## 7. Serve over HTTPS (remote MCP)
Everything above uses **stdio** — the local transport an MCP client launches on your machine.
To reach familiar from a **remote** MCP client (for example a [chat.mistral.ai](https://chat.mistral.ai)
custom connector), run the opt-in **streamable-HTTP** transport instead. stdio stays the default;
nothing here changes how `uv run familiar` behaves with no arguments.
> This section is a quickstart. For the full production setup — Docker, reverse proxy (nginx / Caddy /
> Traefik), the bearer vs OAuth modes, and the env-var reference — see [deployment](deployment.md), and
> for exact per-client steps see [doc/clients/](clients/).
```bash
FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve
```
`familiar serve` binds `127.0.0.1:8080/mcp` behind a TLS-terminating reverse proxy. It **refuses to
start without `FAMILIAR_HTTP_TOKEN`** — an internet-reachable account window must never run
unauthenticated. A request without a valid `Authorization: Bearer <token>` gets `401` with a
`WWW-Authenticate: Bearer` header; a valid token lets the MCP handshake through.
| Variable | Default | Meaning |
| --- | --- | --- |
| `FAMILIAR_HTTP_TOKEN` | *(required)* | Bearer token the client must send; serve refuses to start without it. |
| `FAMILIAR_HTTP_HOST` | `127.0.0.1` | Bind address. Leave loopback; let the proxy face the internet. |
| `FAMILIAR_HTTP_PORT` | `8080` | Bind port. |
| `FAMILIAR_HTTP_PATH` | `/mcp` | MCP endpoint path. |
| `FAMILIAR_ENABLE_WRITES` | *(unset)* | In HTTP mode writes default **off**; set to `1`/`true` to opt in. |
> **Writes default off over HTTP.** A network-reachable, LLM-driven endpoint should not send mail or
> set waypoints unless you opt in explicitly with `FAMILIAR_ENABLE_WRITES=1`. (Over stdio they stay on
> by default, as in section 6.)
### nginx location block
Streamable HTTP holds a long-lived response open, so turn off buffering and raise the read timeout:
```nginx
location /mcp {
proxy_pass http://127.0.0.1:8080/mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # stream events straight through
proxy_read_timeout 3600s; # don't cut long-lived MCP sessions
}
```
### Logging in on a headless server
`familiar login` needs a local browser and the `localhost:8765` callback. On a headless box:
- **Headless login** — print the SSO URL, authorize in any browser, paste the redirected URL back:
```bash
uv run familiar login --no-browser
```
The `localhost` page the browser lands on fails to load — that's expected; copy its address bar
(it holds the `code`) and paste it at the prompt.
- **Copy the store** — run `familiar login` on your desktop, then copy the encrypted store
(`token.enc` + `key` from your `FAMILIAR_DATA_DIR`) to the server's data dir.
### Run it in Docker
CI publishes a container image on every merge to `dev`/`main`
(`git.hiddenden.cafe/hiddenden/familiar`, tags `:dev` and `:main`/`:latest`). It runs `familiar serve`
as a non-root user on port `8080`, with a `/healthz` healthcheck and the encrypted token store under a
`/data` volume. Use the bundled [`docker-compose.yml`](../docker-compose.yml): copy
[`.env.example`](../.env.example) to `.env`, set `FAMILIAR_HTTP_TOKEN`, put your logged-in store in
`./data`, then `docker compose up -d`. It publishes on `127.0.0.1:8080` only — the nginx block above
proxies to it. The container refuses to start without `FAMILIAR_HTTP_TOKEN`.
### Add it in Mistral
In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**,
set the URL to `https://your-host/mcp`, choose **Bearer Token** auth, and paste your
`FAMILIAR_HTTP_TOKEN`.
## 8. Verify everything works
```bash ```bash
uv run pytest # full test suite (fails under 80% coverage) uv run pytest # full test suite (fails under 80% coverage)
+70
View File
@@ -0,0 +1,70 @@
services:
familiar:
# Pull the image published to Gitea by .gitea/workflows/docker.yml, or build
# locally with `docker compose build`. Pick the channel via .env:
# FAMILIAR_IMAGE (:latest / :main for prod, :dev for the dev channel).
image: ${FAMILIAR_IMAGE:-git.hiddenden.cafe/hiddenden/familiar:latest}
build:
context: .
dockerfile: docker/Dockerfile
container_name: familiar
restart: unless-stopped
env_file:
- .env
environment:
FAMILIAR_HTTP_HOST: 0.0.0.0
FAMILIAR_HTTP_PORT: "8080"
FAMILIAR_HTTP_PATH: ${FAMILIAR_HTTP_PATH:-/mcp}
FAMILIAR_DATA_DIR: /data
# bearer (default) or oauth. In bearer mode FAMILIAR_HTTP_TOKEN is required.
FAMILIAR_AUTH_MODE: ${FAMILIAR_AUTH_MODE:-bearer}
# Bearer token clients send as `Authorization: Bearer <token>`. REQUIRED in
# bearer mode — `familiar serve` refuses to start on 0.0.0.0 without it
# (unless FAMILIAR_ALLOW_INSECURE_BIND=true). Leave blank for oauth mode.
FAMILIAR_HTTP_TOKEN: ${FAMILIAR_HTTP_TOKEN:-}
# OAuth 2.1 resource-server settings (only used when AUTH_MODE=oauth).
FAMILIAR_OAUTH_ISSUER: ${FAMILIAR_OAUTH_ISSUER:-}
FAMILIAR_OAUTH_RESOURCE: ${FAMILIAR_OAUTH_RESOURCE:-}
FAMILIAR_OAUTH_AUDIENCE: ${FAMILIAR_OAUTH_AUDIENCE:-}
FAMILIAR_OAUTH_JWKS_URI: ${FAMILIAR_OAUTH_JWKS_URI:-}
# Writes are off by default over HTTP; set true in .env to allow them.
FAMILIAR_ENABLE_WRITES: ${FAMILIAR_ENABLE_WRITES:-false}
# The encrypted token store (token.enc + key), audit log, and write toggle
# live under /data. Run `familiar login` (or `login --no-browser`) once, then
# point this at that data dir (or copy token.enc + key into ./data).
volumes:
- ${FAMILIAR_DATA_DIR_HOST:-./data}:/data
# Not published to the host — only reachable on the internal proxy network,
# where your TLS reverse proxy terminates HTTPS and forwards to :8080.
expose:
- "8080"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1000:1000"
networks:
- proxy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5).raise_for_status()",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# No proxy labels: Pangolin discovers this service on the shared `proxy`
# network and forwards to :8080 — configure the route in Pangolin, not here.
networks:
# Shared reverse-proxy network (Pangolin). Create once with:
# docker network create proxy
proxy:
external: true
+59
View File
@@ -0,0 +1,59 @@
# syntax=docker/dockerfile:1
# --------------------------------------------------------------------------- #
# Build stage: build a wheel from the source tree with uv.
# --------------------------------------------------------------------------- #
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN pip install --no-cache-dir uv
# Files needed to build the wheel (pyproject references README.md + LICENSE).
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN uv build --wheel --out-dir /dist
# --------------------------------------------------------------------------- #
# Runtime stage: minimal, non-root image that runs `familiar serve` (HTTP).
# --------------------------------------------------------------------------- #
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
# Bind on all interfaces inside the container; a TLS reverse proxy fronts it.
FAMILIAR_HTTP_HOST=0.0.0.0 \
FAMILIAR_HTTP_PORT=8080 \
FAMILIAR_HTTP_PATH=/mcp \
# Token store + audit log + write toggle live here — mount a persistent volume.
FAMILIAR_DATA_DIR=/data
# FAMILIAR_HTTP_TOKEN is intentionally NOT baked in: `familiar serve` refuses
# to start without it, so it must be supplied at run time (docker run -e ...).
# Writes stay off by default over HTTP; set FAMILIAR_ENABLE_WRITES=true to opt in.
WORKDIR /app
# Non-root runtime user; /data owned by it so token refresh can write.
RUN useradd -m -u 1000 -s /usr/sbin/nologin familiar \
&& mkdir -p /data \
&& chown -R familiar:familiar /data
COPY --from=builder /dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
USER familiar
EXPOSE 8080
VOLUME ["/data"]
# Liveness against the unauthenticated /healthz route (no token needed).
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import os,httpx; httpx.get(f\"http://127.0.0.1:{os.environ.get('FAMILIAR_HTTP_PORT','8080')}/healthz\", timeout=5).raise_for_status()" || exit 1
CMD ["familiar", "serve"]
+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastmcp>=2.3.0", "fastmcp>=3.0.0", # streamable-HTTP transport + StaticTokenVerifier bearer auth
"httpx>=0.27.0", "httpx>=0.27.0",
"cryptography>=42.0.0", "cryptography>=42.0.0",
] ]
+14 -1
View File
@@ -4,7 +4,10 @@ With no arguments it runs the MCP server over stdio (how Claude Desktop /
Claude Code / Cowork launch it). The auth subcommands are meant to be run by 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 over stdio (default)
familiar serve # run the streamable-HTTP transport (remote MCP)
familiar login # add a character via EVE SSO (run once per character) familiar login # add a character via EVE SSO (run once per character)
familiar login --no-browser # headless login: paste the callback URL on stdin
familiar status # list logged-in characters familiar status # list logged-in characters
familiar logout # forget ALL characters familiar logout # forget ALL characters
familiar logout NAME # forget one character (by name or id) familiar logout NAME # forget one character (by name or id)
@@ -35,13 +38,22 @@ def _human_size(num: int) -> str:
def _login() -> int: def _login() -> int:
bundle = auth.interactive_login() no_browser = any(a in {"--no-browser", "--headless"} for a in sys.argv[2:])
bundle = auth.manual_login() if no_browser else auth.interactive_login()
print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).") print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).")
chars = auth.list_characters() chars = auth.list_characters()
print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}") print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}")
return 0 return 0
def _serve() -> int:
"""Run the streamable-HTTP transport (remote MCP). Refuses without a token."""
from .server import serve_http
serve_http()
return 0
def _status() -> int: def _status() -> int:
chars = auth.list_characters() chars = auth.list_characters()
if not chars: if not chars:
@@ -101,6 +113,7 @@ def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else None command = sys.argv[1] if len(sys.argv) > 1 else None
handlers = { handlers = {
"login": _login, "login": _login,
"serve": _serve,
"status": _status, "status": _status,
"logout": _logout, "logout": _logout,
"writes": _writes, "writes": _writes,
+47
View File
@@ -16,6 +16,7 @@ import os
import secrets import secrets
import time import time
import webbrowser import webbrowser
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse from urllib.parse import parse_qs, urlencode, urlparse
@@ -328,6 +329,52 @@ def interactive_login(*, open_browser: bool = True) -> dict[str, Any]:
return bundle return bundle
def parse_callback(raw: str) -> tuple[str, str | None]:
"""Pull ``(code, state)`` out of a pasted callback URL, query, or bare code.
Accepts the full redirect URL (``http://localhost:8765/callback?code=..&state=..``),
just the query string, or the bare authorization code. Returns the code and
the state if one was present (``None`` otherwise). Raises if no code is found.
"""
raw = raw.strip()
if "code=" in raw:
query = urlparse(raw).query or raw
qs = parse_qs(query)
code = qs.get("code", [None])[0]
state = qs.get("state", [None])[0]
else:
code, state = (raw or None), None
if not code:
raise AuthError("No authorization code found in the pasted callback.")
return code, state
def manual_login(*, read_input: Callable[[str], str] = input) -> dict[str, Any]:
"""Headless PKCE login: print the authorize URL, read the callback from stdin.
For servers with no local browser. Prints the EVE SSO URL (open it anywhere),
then reads the pasted callback URL — or a bare code — from ``read_input``.
The browser's redirect to the (unreachable) localhost callback fails
harmlessly; the code is in its address bar. No local listener is started.
"""
verifier, challenge = generate_pkce()
state = secrets.token_urlsafe(16)
url = build_authorize_url(state, challenge)
print(
"Open this URL in any browser, authorize, then copy the address bar of "
"the page it redirects to (the localhost page will fail to load — that's "
"expected):\n\n"
f"{url}\n"
)
code, got_state = parse_callback(read_input("Paste the callback URL (or code): "))
if got_state is not None and got_state != state:
raise AuthError("State mismatch — possible CSRF; aborting login.")
resp = asyncio.run(exchange_code(code, verifier))
bundle = _bundle_from_response(resp, now=time.time())
_save_character(bundle)
return bundle
def status() -> dict[str, Any]: def status() -> dict[str, Any]:
"""Report which characters are logged in (no network call).""" """Report which characters are logged in (no network call)."""
chars = list_characters() chars = list_characters()
+84
View File
@@ -111,6 +111,90 @@ 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()
# --------------------------------------------------------------------------- #
# HTTP (remote MCP) transport — opt-in via `familiar serve`.
# --------------------------------------------------------------------------- #
# stdio stays the default (how Claude launches familiar locally). `familiar
# serve` runs the streamable-HTTP transport instead, meant to sit behind a TLS
# reverse proxy which terminates HTTPS — so the default bind is loopback. Every
# value is env-overridable; the token has no default on purpose (see
# ``http_token`` — HTTP mode refuses to start without one).
HTTP_HOST = os.environ.get("FAMILIAR_HTTP_HOST", "127.0.0.1")
HTTP_PORT = int(os.environ.get("FAMILIAR_HTTP_PORT", "8080"))
HTTP_PATH = os.environ.get("FAMILIAR_HTTP_PATH", "/mcp")
def http_token() -> str:
"""The expected bearer token for the HTTP transport (empty if unset).
Read live from ``FAMILIAR_HTTP_TOKEN`` so `familiar serve` can refuse to
start when it is missing rather than exposing an unauthenticated server.
"""
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
# Hosts that are *not* reachable from off-box. A bind to anything else is a
# public interface and must be authenticated (or explicitly waived).
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""}
def is_loopback(host: str) -> bool:
"""True if binding ``host`` only exposes the server to the local machine."""
return (host or "").strip().lower() in LOOPBACK_HOSTS
def _env_truthy(name: str) -> bool:
return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"}
def allow_insecure_bind() -> bool:
"""Escape hatch: allow a public (non-loopback) bind without a token.
Off by default. Only honor ``FAMILIAR_ALLOW_INSECURE_BIND`` when the operator
really means to run unauthenticated (e.g. auth handled entirely upstream).
"""
return _env_truthy("FAMILIAR_ALLOW_INSECURE_BIND")
# --------------------------------------------------------------------------- #
# HTTP auth mode: bearer (default) or oauth (OAuth 2.1 resource server).
# --------------------------------------------------------------------------- #
def auth_mode() -> str:
"""``bearer`` (default) or ``oauth`` — how the HTTP transport authenticates.
``bearer``: a single static token in ``FAMILIAR_HTTP_TOKEN``.
``oauth``: validate external-issuer JWTs (JWKS) and advertise the
protected-resource metadata so OAuth-only clients (e.g. Claude web) can
discover the authorization server.
"""
mode = (os.environ.get("FAMILIAR_AUTH_MODE") or "bearer").strip().lower()
return mode if mode in {"bearer", "oauth"} else "bearer"
def oauth_issuer() -> str:
return (os.environ.get("FAMILIAR_OAUTH_ISSUER") or "").strip()
def oauth_audience() -> str:
"""Expected token audience. Defaults to the resource URL when unset."""
aud = (os.environ.get("FAMILIAR_OAUTH_AUDIENCE") or "").strip()
return aud or oauth_resource()
def oauth_resource() -> str:
"""This server's public base URL (the OAuth *resource*), e.g. https://host.
Used as the RemoteAuthProvider base_url and advertised in the
protected-resource metadata. Falls back to FAMILIAR_HTTP_PATH's origin only
if explicitly set; otherwise empty (serve will require it in oauth mode).
"""
return (os.environ.get("FAMILIAR_OAUTH_RESOURCE") or "").strip()
def oauth_jwks_uri() -> str:
"""Explicit JWKS URI, or empty to discover it from the issuer at start."""
return (os.environ.get("FAMILIAR_OAUTH_JWKS_URI") or "").strip()
def data_dir() -> Path: 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."""
+143
View File
@@ -8,13 +8,17 @@ same `mcp` instance.
from __future__ import annotations from __future__ import annotations
import datetime as _dt import datetime as _dt
import sys
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,
@@ -46,6 +50,16 @@ mcp: FastMCP = FastMCP(
) )
@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(_request: Request) -> JSONResponse:
"""Unauthenticated liveness probe for Docker / reverse-proxy healthchecks.
Only reachable over the HTTP transport; on stdio the route is never served.
Deliberately outside the bearer-auth gate so a probe needs no token.
"""
return JSONResponse({"status": "ok", "server": "familiar", "version": __version__})
@mcp.tool @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.
@@ -107,3 +121,132 @@ ui.register(mcp)
# Phase 4: planetary interaction (read-only). # Phase 4: planetary interaction (read-only).
planets.register(mcp) planets.register(mcp)
# --------------------------------------------------------------------------- #
# Remote (HTTP) transport — opt-in via `familiar serve`. stdio stays default.
# --------------------------------------------------------------------------- #
def _bearer_verifier(token: str):
"""A FastMCP verifier that accepts exactly one bearer token.
Uses FastMCP's built-in StaticTokenVerifier: a request with a matching
``Authorization: Bearer <token>`` passes; anything else gets a 401 with a
``WWW-Authenticate: Bearer`` header. Imported lazily so the stdio path never
pulls in the auth stack.
"""
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str:
"""Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset."""
if jwks_uri:
return jwks_uri
import httpx
disco = issuer.rstrip("/") + "/.well-known/openid-configuration"
try:
resp = httpx.get(disco, timeout=10.0)
resp.raise_for_status()
uri = resp.json().get("jwks_uri")
except Exception as exc: # noqa: BLE001 — surface any discovery failure as a clean exit
raise SystemExit(
f"OAuth discovery failed at {disco}: {exc}. Set FAMILIAR_OAUTH_JWKS_URI explicitly."
) from exc
if not uri:
raise SystemExit(f"OIDC metadata at {disco} has no jwks_uri; set FAMILIAR_OAUTH_JWKS_URI.")
return uri
def _oauth_provider(verifier=None):
"""Build the OAuth 2.1 resource-server provider (validates external JWTs).
familiar acts as a *resource server* only: it validates incoming JWT access
tokens against an external issuer's JWKS and advertises
``/.well-known/oauth-protected-resource`` so OAuth-only clients can discover
the authorization server. It is NOT an authorization server and does no DCR.
``verifier`` is injectable for tests; production builds a JWTVerifier.
"""
from pydantic import AnyHttpUrl
from fastmcp.server.auth import RemoteAuthProvider
issuer = config.oauth_issuer()
resource = config.oauth_resource()
if not issuer or not resource:
raise SystemExit(
"oauth mode needs FAMILIAR_OAUTH_ISSUER (the authorization server) and "
"FAMILIAR_OAUTH_RESOURCE (this server's public https URL)."
)
if verifier is None:
from fastmcp.server.auth.providers.jwt import JWTVerifier
jwks = _resolve_jwks_uri(issuer, config.oauth_jwks_uri())
verifier = JWTVerifier(jwks_uri=jwks, issuer=issuer, audience=config.oauth_audience())
return RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl(issuer)],
base_url=resource,
)
def _refuse_no_token(host: str) -> "SystemExit":
where = "a public interface" if not config.is_loopback(host) else "loopback"
return SystemExit(
f"Refusing to start the HTTP server on {host!r} ({where}) without a bearer "
"token. Set FAMILIAR_HTTP_TOKEN to a long random secret, switch to "
"FAMILIAR_AUTH_MODE=oauth, or — only if auth is handled upstream — set "
"FAMILIAR_ALLOW_INSECURE_BIND=true to run unauthenticated."
)
def configure_http(*, host: str | None = None, token: str | None = None, verifier=None) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving; return the mode/token.
Attaches the auth provider for the configured ``FAMILIAR_AUTH_MODE`` and
switches writes to their network default (disabled unless
FAMILIAR_ENABLE_WRITES is explicitly truthy). Fail-closed: a bind without a
token (bearer mode) raises ``SystemExit`` unless FAMILIAR_ALLOW_INSECURE_BIND
is set. ``verifier`` is injectable for oauth-mode tests.
"""
host = host if host is not None else config.HTTP_HOST
writes.set_http_context(True)
if config.auth_mode() == "oauth":
mcp.auth = _oauth_provider(verifier=verifier)
return "oauth"
token = token if token is not None else config.http_token()
if not token:
if config.allow_insecure_bind():
print(
f"WARNING: serving familiar on {host!r} with NO authentication "
"(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this "
"port has full access to your account.",
file=sys.stderr,
)
mcp.auth = None
return ""
raise _refuse_no_token(host)
mcp.auth = _bearer_verifier(token)
return token
def serve_http(
*, host: str | None = None, port: int | None = None, path: str | None = None
) -> None:
"""Run familiar over the streamable-HTTP transport.
Binds loopback by default (a TLS reverse proxy is expected to terminate
HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default).
"""
host = host or config.HTTP_HOST
configure_http(host=host)
mcp.run(
transport="http",
host=host,
port=port or config.HTTP_PORT,
path=path or config.HTTP_PATH,
)
+22 -4
View File
@@ -28,15 +28,33 @@ def _flag_file():
return config.data_dir() / "writes_disabled" return config.data_dir() / "writes_disabled"
def writes_enabled() -> bool: # Flipped on by `familiar serve` (HTTP transport). It only changes what the
"""True if writes are allowed (env override or the on-disk toggle). # *default* is when FAMILIAR_ENABLE_WRITES is unset: a network-reachable,
# LLM-driven endpoint should not send mail or set waypoints unless the operator
# opted in explicitly, so in HTTP mode the default is disabled rather than
# enabled. An explicit env value (either way) still wins.
_http_context = False
Writes are **enabled by default**; the user opts out by creating the
disable-flag file (`familiar writes disable`) or setting the env var. def set_http_context(enabled: bool) -> None:
"""Mark the process as serving over HTTP so writes default to disabled."""
global _http_context
_http_context = enabled
def writes_enabled() -> bool:
"""True if writes are allowed (env override, HTTP default, or on-disk toggle).
Precedence: an explicit ``FAMILIAR_ENABLE_WRITES`` wins. With it unset,
writes are **disabled by default in HTTP mode** (see :func:`set_http_context`)
and **enabled by default** otherwise (the local stdio case), unless the user
opted out with the disable-flag file (`familiar writes disable`).
""" """
env = os.environ.get("FAMILIAR_ENABLE_WRITES") env = os.environ.get("FAMILIAR_ENABLE_WRITES")
if env is not None: if env is not None:
return env.strip().lower() in {"1", "true", "yes", "on"} return env.strip().lower() in {"1", "true", "yes", "on"}
if _http_context:
return False
return not _flag_file().exists() return not _flag_file().exists()
+393
View File
@@ -0,0 +1,393 @@
"""Tests for the opt-in HTTP transport, bearer auth, and headless login.
The stdio path and every existing tool stay untouched; these only exercise the
new `familiar serve` surface: the bearer verifier (401 without / with a bad
token, through with a good one), the start-refusal when no token is set, the
FAMILIAR_HTTP_* config defaults, the write-default flip in HTTP mode, and the
headless-login callback parsing.
"""
from __future__ import annotations
import asyncio
import importlib
import httpx
import pytest
from conftest import make_jwt
from familiar import auth, config, server, writes
# --------------------------------------------------------------------------- #
# Bearer auth on the HTTP transport
# --------------------------------------------------------------------------- #
def _initialize_request() -> dict:
return {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
}
async def _status_for(app, authorization: str | None) -> int:
"""POST an MCP initialize to the ASGI app and return the HTTP status."""
headers = {"Accept": "application/json, text/event-stream"}
if authorization is not None:
headers["Authorization"] = authorization
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/mcp", json=_initialize_request(), headers=headers)
return resp.status_code, resp.headers.get("www-authenticate")
@pytest.fixture
def http_server(monkeypatch):
"""Configure the shared mcp for HTTP with a known token, then reset it."""
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", "s3cret-token")
original_auth = server.mcp.auth
token = server.configure_http()
app = server.mcp.http_app(path="/mcp")
yield token, app
server.mcp.auth = original_auth
writes.set_http_context(False)
def test_missing_token_is_401_with_www_authenticate(http_server):
_, app = http_server
status, www_auth = asyncio.run(_status_for(app, None))
assert status == 401
assert www_auth and www_auth.lower().startswith("bearer")
def test_wrong_token_is_401(http_server):
_, app = http_server
status, _ = asyncio.run(_status_for(app, "Bearer nope"))
assert status == 401
def test_valid_token_passes_handshake(http_server):
token, app = http_server
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 200
def test_healthz_needs_no_token(http_server):
_, app = http_server
async def _get_health():
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/healthz")
resp = asyncio.run(_get_health())
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# --------------------------------------------------------------------------- #
# Start refusal without a token
# --------------------------------------------------------------------------- #
def test_configure_http_refuses_without_token(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
original_auth = server.mcp.auth
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http()
assert "FAMILIAR_HTTP_TOKEN" in str(excinfo.value)
finally:
server.mcp.auth = original_auth
writes.set_http_context(False)
def test_configure_http_refuses_on_blank_token(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", " ")
original_auth = server.mcp.auth
try:
with pytest.raises(SystemExit):
server.configure_http()
finally:
server.mcp.auth = original_auth
writes.set_http_context(False)
# --------------------------------------------------------------------------- #
# Config defaults for the new env vars
# --------------------------------------------------------------------------- #
def test_http_config_defaults(monkeypatch):
for var in ("FAMILIAR_HTTP_HOST", "FAMILIAR_HTTP_PORT", "FAMILIAR_HTTP_PATH"):
monkeypatch.delenv(var, raising=False)
try:
importlib.reload(config)
assert config.HTTP_HOST == "127.0.0.1"
assert config.HTTP_PORT == 8080
assert config.HTTP_PATH == "/mcp"
finally:
importlib.reload(config)
def test_http_config_overrides(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_HOST", "0.0.0.0")
monkeypatch.setenv("FAMILIAR_HTTP_PORT", "9001")
monkeypatch.setenv("FAMILIAR_HTTP_PATH", "/eve")
try:
importlib.reload(config)
assert config.HTTP_HOST == "0.0.0.0"
assert config.HTTP_PORT == 9001
assert config.HTTP_PATH == "/eve"
finally:
for var in ("FAMILIAR_HTTP_HOST", "FAMILIAR_HTTP_PORT", "FAMILIAR_HTTP_PATH"):
monkeypatch.delenv(var, raising=False)
importlib.reload(config)
def test_http_token_reads_and_strips_env(monkeypatch):
monkeypatch.setenv("FAMILIAR_HTTP_TOKEN", " padded ")
assert config.http_token() == "padded"
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
assert config.http_token() == ""
# --------------------------------------------------------------------------- #
# Write default in the HTTP context
# --------------------------------------------------------------------------- #
def test_http_context_disables_writes_by_default(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False)
try:
writes.set_http_context(True)
assert writes.writes_enabled() is False
finally:
writes.set_http_context(False)
def test_http_context_write_env_still_wins(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "1")
try:
writes.set_http_context(True)
assert writes.writes_enabled() is True
finally:
writes.set_http_context(False)
def test_stdio_writes_stay_enabled_by_default(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False)
writes.set_http_context(False)
assert writes.writes_enabled() is True
# --------------------------------------------------------------------------- #
# Headless login callback parsing + flow
# --------------------------------------------------------------------------- #
def test_parse_callback_full_url():
code, state = auth.parse_callback("http://localhost:8765/callback?code=abc123&state=xyz")
assert code == "abc123"
assert state == "xyz"
def test_parse_callback_bare_code():
code, state = auth.parse_callback(" justthecode ")
assert code == "justthecode"
assert state is None
def test_parse_callback_rejects_empty():
with pytest.raises(auth.AuthError):
auth.parse_callback("")
def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
saved: dict = {}
async def fake_exchange(code, verifier, **kwargs):
assert code == "the-code"
return {
"access_token": make_jwt({"sub": "CHARACTER:EVE:42", "name": "Pilot", "scp": []}),
"refresh_token": "refresh-xyz",
"expires_in": 1200,
}
monkeypatch.setattr(auth, "exchange_code", fake_exchange)
monkeypatch.setattr(auth, "_save_character", lambda bundle: saved.update(bundle))
bundle = auth.manual_login(
read_input=lambda _prompt: "http://localhost:8765/callback?code=the-code"
)
assert bundle["character_id"] == 42
assert bundle["character_name"] == "Pilot"
assert saved["character_id"] == 42
# --------------------------------------------------------------------------- #
# Fail-closed bind + insecure escape hatch
# --------------------------------------------------------------------------- #
def _reset_auth():
server.mcp.auth = None
writes.set_http_context(False)
def test_public_bind_without_token_refused(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http(host="0.0.0.0")
assert "public interface" in str(excinfo.value)
finally:
_reset_auth()
def test_loopback_without_token_still_refused(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
with pytest.raises(SystemExit):
server.configure_http(host="127.0.0.1")
finally:
_reset_auth()
def test_insecure_bind_escape_hatch_allows_tokenless(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "true")
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
result = server.configure_http(host="0.0.0.0")
assert result == ""
assert server.mcp.auth is None
finally:
_reset_auth()
# --------------------------------------------------------------------------- #
# OAuth resource-server mode
# --------------------------------------------------------------------------- #
_ISS = "https://idp.example.com"
_RES = "https://familiar.example.com"
@pytest.fixture
def oauth_server(monkeypatch):
"""Configure oauth mode with an injected JWTVerifier over a local RSA key."""
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
monkeypatch.setenv("FAMILIAR_OAUTH_ISSUER", _ISS)
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", _RES)
original = server.mcp.auth
kp = RSAKeyPair.generate()
verifier = JWTVerifier(public_key=kp.public_key, issuer=_ISS, audience=_RES)
server.configure_http(host="0.0.0.0", verifier=verifier)
app = server.mcp.http_app(path="/mcp")
yield kp, app
server.mcp.auth = original
writes.set_http_context(False)
def test_oauth_valid_jwt_passes(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1")
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 200
def test_oauth_missing_token_401(oauth_server):
_, app = oauth_server
status, www_auth = asyncio.run(_status_for(app, None))
assert status == 401
assert www_auth and www_auth.lower().startswith("bearer")
def test_oauth_wrong_audience_401(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience="https://other.example.com", subject="user1")
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 401
def test_oauth_expired_token_401(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1", expires_in_seconds=-60)
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 401
def test_oauth_protected_resource_metadata(oauth_server):
_, app = oauth_server
async def _get_metadata():
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/.well-known/oauth-protected-resource/mcp")
resp = asyncio.run(_get_metadata())
assert resp.status_code == 200
body = resp.json()
assert body["resource"] == f"{_RES}/mcp"
assert body["authorization_servers"] == [f"{_ISS}/"]
def test_oauth_requires_issuer_and_resource(monkeypatch):
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
monkeypatch.delenv("FAMILIAR_OAUTH_ISSUER", raising=False)
monkeypatch.delenv("FAMILIAR_OAUTH_RESOURCE", raising=False)
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http(host="0.0.0.0")
assert "FAMILIAR_OAUTH_ISSUER" in str(excinfo.value)
finally:
_reset_auth()
def test_resolve_jwks_uri_passthrough():
assert server._resolve_jwks_uri(_ISS, "https://idp/keys") == "https://idp/keys"
# --------------------------------------------------------------------------- #
# Config readers for the new env vars
# --------------------------------------------------------------------------- #
def test_is_loopback():
assert config.is_loopback("127.0.0.1")
assert config.is_loopback("localhost")
assert not config.is_loopback("0.0.0.0")
assert not config.is_loopback("192.168.1.10")
def test_auth_mode_default_and_values(monkeypatch):
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
assert config.auth_mode() == "bearer"
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "OAuth")
assert config.auth_mode() == "oauth"
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "nonsense")
assert config.auth_mode() == "bearer"
def test_allow_insecure_bind(monkeypatch):
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
assert config.allow_insecure_bind() is False
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes")
assert config.allow_insecure_bind() is True
def test_oauth_audience_defaults_to_resource(monkeypatch):
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
monkeypatch.delenv("FAMILIAR_OAUTH_AUDIENCE", raising=False)
assert config.oauth_audience() == _RES
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", "custom-aud")
assert config.oauth_audience() == "custom-aud"
Generated
+1 -1
View File
@@ -439,7 +439,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cryptography", specifier = ">=42.0.0" }, { name = "cryptography", specifier = ">=42.0.0" },
{ name = "fastmcp", specifier = ">=2.3.0" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
] ]