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
Owner

What

Builds on the merged familiar serve HTTP transport (#63) to make familiar production-ready as a remote MCP server: a Docker image that's built and pushed to the Gitea registry on every merge to dev/main, hardening for a public bind, an optional OAuth mode for the claude.ai web connector, and complete per-client connection docs.

stdio is untouched: familiar / python -m familiar still run exactly as before.

Serve hardening

  • /healthz — an unauthenticated liveness route returning only {status, server, version} (no account data, no token state), for Docker/proxy probes. Never served over stdio.
  • Fail-closed bind — familiar already refused to start without a token; now the refusal message distinguishes a loopback bind from a public (0.0.0.0) one, and there's an explicit FAMILIAR_ALLOW_INSECURE_BIND=true escape hatch to run unauthenticated (with a loud warning) when auth is handled upstream.
  • FAMILIAR_AUTH_MODE=oauth — opt-in OAuth 2.1 resource server. A JWTVerifier validates external-issuer JWTs (JWKS via FAMILIAR_OAUTH_JWKS_URI, or discovered from FAMILIAR_OAUTH_ISSUER's OIDC metadata) against issuer + audience, wrapped in a RemoteAuthProvider that serves /.well-known/oauth-protected-resource/<path>. familiar is a resource server only — no authorization server, no DCR; that needs an external IdP (Authentik/Keycloak). bearer mode is unchanged and stays the default; needed only for the claude.ai web connector.

Docker + CI

  • docker/Dockerfile — multi-stage uv wheel build; runtime runs familiar serve as a non-root user (uid 1000) on port 8080, /data volume for the encrypted token store, HEALTHCHECK on /healthz. No token baked in.
  • docker-compose.yml — external proxy network, Traefik TLS labels (nginx/Caddy documented as alternatives), read-only rootfs, tmpfs /tmp, no-new-privileges, cap_drop: ALL, non-root; pulls FAMILIAR_IMAGE with a local build fallback; writes off by default.
  • .gitea/workflows/docker.yml — a standalone workflow (the existing ci.yml wheel publish is left intact). Lint → test → build → smoke-test (non-root uid, /healthz 200, MCP 401 without / 200 with a token) → push on a main/dev merge to git.hiddenden.cafe/hiddenden/familiar (main → :latest/:main, dev → :dev, plus :sha-<12>) via the existing REGISTRY_TOKEN, then scoped cleanup.
  • .dockerignore + .env.example (every FAMILIAR_* incl. auth mode, OAuth settings, escape hatch).

Docs

  • doc/deployment.md — remote-server guide: secure defaults, bearer vs OAuth, full env-var reference, one-time login / token-store provisioning (incl. headless), published images, docker compose, and TLS reverse-proxy blocks for nginx (proxy_buffering off; + long read timeout), Caddy, and Traefik.
  • doc/clients/ — one page each with exact steps: Claude Code, VS Code (.vscode/mcp.json with a masked token input + the workspace-.mcp.json header gotcha), Mistral Le Chat, Claude Desktop + Cowork (mcp-remote bridge, per-OS paths), and Claude web (oauth + external IdP, with "use Desktop + bearer" as the simpler alternative). Plus an index.
  • README gets a Connect a client matrix + a compact Deploy with Docker section; doc/README.md index and CLAUDE.md env vars updated.

Note: I placed the new docs under the repo's existing doc/ directory (not a separate docs/) to keep a single documentation tree.

Tests

Extends tests/test_serve.py (conftest style, no async plugin): /healthz (200, no leak); fail-closed public/loopback bind + the escape hatch; oauth (valid JWT 200; missing/expired/wrong-audience 401 + WWW-Authenticate; protected-resource metadata contents); plus the new config readers. Bearer mode and the stdio path are unchanged.

uv run ruff format --check . && uv run ruff check . && uv run pytest is green — 189 passed, coverage 90% (≥ 80%). The OAuth path was also verified end-to-end in-process with a local RSA keypair (valid/expired/wrong-audience → correct 401/200 and the metadata endpoint).

## What Builds on the merged `familiar serve` HTTP transport (#63) to make familiar production-ready as a remote MCP server: a Docker image that's built and pushed to the Gitea registry on every merge to `dev`/`main`, hardening for a public bind, an optional OAuth mode for the claude.ai web connector, and complete per-client connection docs. **stdio is untouched**: `familiar` / `python -m familiar` still run exactly as before. ## Serve hardening - **`/healthz`** — an unauthenticated liveness route returning only `{status, server, version}` (no account data, no token state), for Docker/proxy probes. Never served over stdio. - **Fail-closed bind** — familiar already refused to start without a token; now the refusal message distinguishes a loopback bind from a public (`0.0.0.0`) one, and there's an explicit `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch to run unauthenticated (with a loud warning) when auth is handled upstream. - **`FAMILIAR_AUTH_MODE=oauth`** — opt-in OAuth 2.1 **resource server**. A `JWTVerifier` validates external-issuer JWTs (JWKS via `FAMILIAR_OAUTH_JWKS_URI`, or discovered from `FAMILIAR_OAUTH_ISSUER`'s OIDC metadata) against issuer + audience, wrapped in a `RemoteAuthProvider` that serves `/.well-known/oauth-protected-resource/<path>`. familiar is a resource server only — **no** authorization server, **no** DCR; that needs an external IdP (Authentik/Keycloak). `bearer` mode is unchanged and stays the default; needed only for the claude.ai web connector. ## Docker + CI - **`docker/Dockerfile`** — multi-stage uv wheel build; runtime runs `familiar serve` as a non-root user (uid 1000) on port 8080, `/data` volume for the encrypted token store, `HEALTHCHECK` on `/healthz`. No token baked in. - **`docker-compose.yml`** — external `proxy` network, Traefik TLS labels (nginx/Caddy documented as alternatives), read-only rootfs, tmpfs `/tmp`, `no-new-privileges`, `cap_drop: ALL`, non-root; pulls `FAMILIAR_IMAGE` with a local build fallback; writes off by default. - **`.gitea/workflows/docker.yml`** — a standalone workflow (the existing `ci.yml` wheel publish is left intact). Lint → test → build → smoke-test (non-root uid, `/healthz` 200, MCP 401 without / 200 with a token) → push on a `main`/`dev` merge to `git.hiddenden.cafe/hiddenden/familiar` (`main` → `:latest`/`:main`, `dev` → `:dev`, plus `:sha-<12>`) via the existing `REGISTRY_TOKEN`, then scoped cleanup. - `.dockerignore` + `.env.example` (every `FAMILIAR_*` incl. auth mode, OAuth settings, escape hatch). ## Docs - **`doc/deployment.md`** — remote-server guide: secure defaults, bearer vs OAuth, full env-var reference, one-time login / token-store provisioning (incl. headless), published images, docker compose, and TLS reverse-proxy blocks for nginx (`proxy_buffering off;` + long read timeout), Caddy, and Traefik. - **`doc/clients/`** — one page each with exact steps: Claude Code, VS Code (`.vscode/mcp.json` with a masked token input + the workspace-`.mcp.json` header gotcha), Mistral Le Chat, Claude Desktop + Cowork (`mcp-remote` bridge, per-OS paths), and Claude web (oauth + external IdP, with "use Desktop + bearer" as the simpler alternative). Plus an index. - README gets a **Connect a client** matrix + a compact **Deploy with Docker** section; `doc/README.md` index and `CLAUDE.md` env vars updated. > Note: I placed the new docs under the repo's existing **`doc/`** directory (not a separate `docs/`) to keep a single documentation tree. ## Tests Extends `tests/test_serve.py` (conftest style, no async plugin): `/healthz` (200, no leak); fail-closed public/loopback bind + the escape hatch; oauth (valid JWT 200; missing/expired/wrong-audience 401 + `WWW-Authenticate`; protected-resource metadata contents); plus the new config readers. Bearer mode and the stdio path are unchanged. `uv run ruff format --check . && uv run ruff check . && uv run pytest` is green — **189 passed, coverage 90%** (≥ 80%). The OAuth path was also verified end-to-end in-process with a local RSA keypair (valid/expired/wrong-audience → correct 401/200 and the metadata endpoint).
Latte added 4 commits 2026-07-04 17:27:41 +00:00
Extend the HTTP transport (from the remote-MCP work) so it's production-ready.

- /healthz: an unauthenticated GET liveness route that returns only
  {status, server, version} — no account data, no token state. For Docker and
  reverse-proxy probes; on stdio the route is never served.
- Fail-closed bind: refuse to start without authentication, and make the
  message distinguish a loopback bind from a public (0.0.0.0) one. The only
  override is the explicit FAMILIAR_ALLOW_INSECURE_BIND=true escape hatch, which
  permits an unauthenticated bind (with a loud warning).
- FAMILIAR_AUTH_MODE=oauth: run as an OAuth 2.1 resource server. A JWTVerifier
  validates external-issuer JWTs (JWKS from FAMILIAR_OAUTH_JWKS_URI, else
  discovered from FAMILIAR_OAUTH_ISSUER's OIDC metadata) against issuer +
  audience, wrapped in a RemoteAuthProvider that serves
  /.well-known/oauth-protected-resource so OAuth-only clients (Claude web) can
  discover the authorization server. familiar is a resource server only — no
  authorization server, no DCR. bearer mode is unchanged and stays the default.

Tests cover healthz (200, no leak), fail-closed vs escape hatch, and oauth
(valid JWT 200; missing/expired/wrong-audience 401 + WWW-Authenticate; the
protected-resource metadata), plus the new config readers.
- docker/Dockerfile: multi-stage uv wheel build; runtime runs `familiar serve`
  as a non-root user (uid 1000) on port 8080, /data volume for the encrypted
  token store, and a HEALTHCHECK against /healthz. No token is baked in — serve
  refuses to start without FAMILIAR_HTTP_TOKEN, so it's supplied at run time.
- docker-compose.yml: joins an external `proxy` network (Traefik labels for TLS,
  nginx/Caddy documented as alternatives), read-only rootfs, tmpfs /tmp,
  no-new-privileges, cap_drop ALL, non-root, /healthz healthcheck. Pulls the
  published FAMILIAR_IMAGE with a local build as fallback; writes default off.
- .env.example: every FAMILIAR_* knob incl. the bearer token, auth mode, the
  OAuth resource-server settings, and the insecure-bind escape hatch.
- .dockerignore: keep local state (data/, *.enc, key) and dev files out.
New .gitea/workflows/docker.yml (alongside the untouched ci.yml wheel publish).
Runs lint + test, then builds the image and smoke-tests it — non-root uid,
/healthz 200, MCP 401 without a token and 200 with a valid bearer. On a merge to
main/dev it pushes to git.hiddenden.cafe/hiddenden/familiar (main -> :latest +
:main, dev -> :dev, plus :sha-<12>) via the existing REGISTRY_TOKEN, then does a
scoped cleanup. The two workflows are independent and don't interfere.
docs: deployment guide + per-client setup + connect matrix
CI / lint (pull_request) Successful in 55s
docker / lint (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m6s
docker / test (pull_request) Successful in 1m4s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Failing after 1m11s
df9e06a3e7
- doc/deployment.md: run familiar as a remote MCP server — secure defaults,
  bearer vs OAuth modes, the full env-var reference, one-time login / token-store
  provisioning, the published images, docker compose, and TLS reverse-proxy
  blocks for nginx (proxy_buffering off + long read timeout), Caddy, and Traefik.
- doc/clients/: one page per client with exact working steps — Claude Code,
  VS Code (.vscode/mcp.json with a masked token input, and the workspace
  .mcp.json header gotcha), Mistral Le Chat, Claude Desktop + Cowork (mcp-remote
  bridge, per-OS config paths), and Claude web (oauth mode + external IdP, with
  "use Desktop + bearer" as the simpler alternative). Plus a clients/ index.
- README: a "Connect a client" matrix (client -> auth -> guide) and a compact
  "Deploy with Docker" section linking to doc/deployment.md; oauth mode noted.
- doc/README.md index: rows for Deployment and Client setup.
- doc/getting-started.md: pointer from the HTTPS quickstart to the full docs.
- CLAUDE.md: FAMILIAR_AUTH_MODE / FAMILIAR_OAUTH_* / FAMILIAR_ALLOW_INSECURE_BIND
  and /healthz in the transport note; the split ci.yml / docker.yml workflows.

Deployment docs live under the existing doc/ tree (the repo uses doc/, not a
separate docs/) to keep a single documentation directory.
Latte merged commit 0fdba707fe into dev 2026-07-05 09:54:01 +00:00
Latte deleted branch feat/docker-serve 2026-07-05 09:54:01 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Hiddenden/familiar#64