feat: production-ready remote MCP — Docker image, CI publish, per-client docs, OAuth mode #64

Merged
Latte merged 4 commits from feat/docker-serve into dev 2026-07-05 09:54:01 +00:00
20 changed files with 1053 additions and 43 deletions

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 (Traefik labels in docker-compose.yml) ------------------ #
# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md.
FAMILIAR_DOMAIN=familiar.example.com
TRAEFIK_CERTRESOLVER=letsencrypt
+131
View File
@@ -0,0 +1,131 @@
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"
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 }}"
TOKEN="smoke-$(date +%s)"
# 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 over HTTP.
docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:18080/healthz" >/dev/null 2>&1; then break; fi
sleep 1
done
echo "== /healthz (expect 200) =="
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18080/healthz")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; }
echo "== MCP without token (expect 401) =="
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \
-H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
-d '{"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=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
-d '{"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 familiar-smoke; 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 familiar-smoke || 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
+9 -2
View File
@@ -30,7 +30,14 @@ 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 (e.g. a Mistral custom connector). `configure_http()` attaches a FastMCP `StaticTokenVerifier` from `FAMILIAR_HTTP_TOKEN` (bad/missing bearer → 401 + `WWW-Authenticate: Bearer`) and **refuses to start (`SystemExit`) if the token is unset**. This transport-level bearer token is separate from the EVE-SSO/ESI auth in auth.py. In HTTP mode `configure_http` also 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). **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`.
@@ -48,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.
+15 -26
View File
@@ -99,39 +99,28 @@ Environment variables:
**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.) **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.)
### nginx 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).
Streamable HTTP keeps a long-lived response open, so disable proxy buffering and give it a generous read timeout: ### Deploy with Docker
```nginx 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.
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
}
```
### Add characters on a headless server **Full guide — Docker, reverse proxy (nginx / Caddy / Traefik), TLS, token provisioning, and the env-var reference — is in [doc/deployment.md](doc/deployment.md).**
`familiar login` opens a browser and captures the callback on localhost, which needs a desktop. On a headless box you have two options: ### Connect a client
1. **Headless login** — print the SSO URL, authorize in any browser, paste the redirected URL back: Pick your client for exact steps:
```bash | Client | Auth | Guide |
uv run familiar login --no-browser | --- | --- | --- |
``` | 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) |
The localhost page the browser lands on will fail to load — that's expected; copy its address bar (it contains the `code`) and paste it at the prompt. 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.
2. **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.
### Connect 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 the `FAMILIAR_HTTP_TOKEN` value.
## Roadmap ## Roadmap
+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. |
+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.
+140
View File
@@ -0,0 +1,140 @@
# 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_DOMAIN,
# cert resolver, 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.
### Traefik (labels included)
`docker-compose.yml` already carries Traefik router labels. Set in `.env`:
```
FAMILIAR_DOMAIN=familiar.yourdomain.tld
TRAEFIK_CERTRESOLVER=letsencrypt
```
Traefik terminates HTTPS and forwards to the container's port 8080 on the `proxy` network. Your public URL is then `https://familiar.yourdomain.tld/mcp`.
## 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.
+14
View File
@@ -106,6 +106,10 @@ To reach familiar from a **remote** MCP client (for example a [chat.mistral.ai](
custom connector), run the opt-in **streamable-HTTP** transport instead. stdio stays the default; 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. 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 ```bash
FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve FAMILIAR_HTTP_TOKEN=$(openssl rand -hex 32) uv run familiar serve
``` ```
@@ -158,6 +162,16 @@ location /mcp {
- **Copy the store** — run `familiar login` on your desktop, then copy the encrypted store - **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. (`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 ### Add it in Mistral
In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**, In [chat.mistral.ai](https://chat.mistral.ai): **Intelligence → Connectors → Add custom connector**,
+80
View File
@@ -0,0 +1,80 @@
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
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. For nginx/Caddy
# instead, ignore these labels — see doc/deployment.md.
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: "8080"
networks:
# Shared reverse-proxy network (Traefik/Caddy). 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"]
+63
View File
@@ -133,6 +133,69 @@ def http_token() -> str:
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip() 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."""
override = os.environ.get("FAMILIAR_DATA_DIR") override = os.environ.get("FAMILIAR_DATA_DIR")
+104 -15
View File
@@ -8,8 +8,11 @@ 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__,
@@ -47,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.
@@ -126,21 +139,96 @@ def _bearer_verifier(token: str):
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}}) return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
def configure_http(token: str | None = None) -> str: def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving and return the token. """Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset."""
if jwks_uri:
return jwks_uri
import httpx
Attaches bearer auth and switches writes to their network default (disabled disco = issuer.rstrip("/") + "/.well-known/openid-configuration"
unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit`` try:
if no token is configured — an internet-reachable server must never run open. 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() token = token if token is not None else config.http_token()
if not token: if not token:
raise SystemExit( if config.allow_insecure_bind():
"Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. " print(
"Set it to a long random secret (the bearer token your MCP client " f"WARNING: serving familiar on {host!r} with NO authentication "
"sends) and try again." "(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this "
) "port has full access to your account.",
writes.set_http_context(True) file=sys.stderr,
)
mcp.auth = None
return ""
raise _refuse_no_token(host)
mcp.auth = _bearer_verifier(token) mcp.auth = _bearer_verifier(token)
return token return token
@@ -148,16 +236,17 @@ def configure_http(token: str | None = None) -> str:
def serve_http( def serve_http(
*, host: str | None = None, port: int | None = None, path: str | None = None *, host: str | None = None, port: int | None = None, path: str | None = None
) -> None: ) -> None:
"""Run familiar over the streamable-HTTP transport with bearer auth. """Run familiar over the streamable-HTTP transport.
Binds loopback by default (a TLS reverse proxy is expected to terminate 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_* HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
config. Refuses to start without FAMILIAR_HTTP_TOKEN. config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default).
""" """
configure_http() host = host or config.HTTP_HOST
configure_http(host=host)
mcp.run( mcp.run(
transport="http", transport="http",
host=host or config.HTTP_HOST, host=host,
port=port or config.HTTP_PORT, port=port or config.HTTP_PORT,
path=path or config.HTTP_PATH, path=path or config.HTTP_PATH,
) )
+177
View File
@@ -79,6 +79,20 @@ def test_valid_token_passes_handshake(http_server):
assert status == 200 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 # Start refusal without a token
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -214,3 +228,166 @@ def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path):
assert bundle["character_id"] == 42 assert bundle["character_id"] == 42
assert bundle["character_name"] == "Pilot" assert bundle["character_name"] == "Pilot"
assert saved["character_id"] == 42 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"