release: remote HTTP MCP — bearer + OAuth, Docker image, CI publish, per-client docs #65

Merged
Latte merged 16 commits from dev into main 2026-07-05 15:50:53 +00:00
16 Commits
Author SHA1 Message Date
Latte d69c994da0 Merge pull request 'fix(ci): give the smoke-test container a per-commit name' (#68) from fix/smoke-test-container-name into dev
CI / lint (push) Successful in 55s
docker / lint (push) Successful in 57s
CI / test (push) Successful in 1m6s
docker / test (push) Successful in 1m6s
CI / lint (pull_request) Successful in 50s
CI / test (pull_request) Successful in 58s
docker / lint (pull_request) Successful in 52s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Successful in 1m18s
docker / test (pull_request) Successful in 1m1s
CI / publish (push) Successful in 54s
docker / docker (push) Successful in 48s
Reviewed-on: #68
2026-07-05 12:00:26 +00:00
Latte 288c696f28 fix(ci): give the smoke-test container a per-commit name
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) Successful in 41s
The Docker smoke-test ran the server container under the fixed name
familiar-smoke. On the self-hosted runner the job shares the host
Docker daemon, so a container left behind by a killed or re-run job
(or a concurrent dev push + PR) collides on that name and 'docker run'
fails with exit 125.

Derive the name from the commit SHA, remove any stale container of that
name before starting, and thread the name through the smoke-test and
cleanup steps.
2026-07-05 13:55:29 +02:00
Latte 6c4cbd13ad Merge pull request 'chore(deploy): drop Traefik labels, use Pangolin as the proxy' (#67) from chore/pangolin-proxy into dev
CI / lint (push) Successful in 51s
CI / test (push) Successful in 57s
docker / lint (push) Successful in 50s
docker / test (push) Successful in 57s
CI / lint (pull_request) Successful in 51s
docker / lint (pull_request) Successful in 51s
CI / test (pull_request) Successful in 1m0s
docker / test (pull_request) Successful in 57s
CI / publish (pull_request) Has been skipped
CI / publish (push) Successful in 53s
docker / docker (pull_request) Failing after 12s
docker / docker (push) Failing after 1m11s
Reviewed-on: #67
2026-07-05 11:46:47 +00:00
Latte aeb79fdbb7 Merge branch 'dev' into chore/pangolin-proxy
docker / test (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 50s
docker / lint (pull_request) Successful in 50s
CI / test (pull_request) Successful in 1m2s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Failing after 22s
2026-07-05 11:46:40 +00:00
Latte 733757f22a Merge pull request 'fix(ci): smoke-test image from inside its container via docker exec' (#66) from fix/smoke-test-container-ip into dev
CI / lint (push) Successful in 55s
docker / lint (push) Successful in 57s
CI / test (push) Successful in 1m6s
docker / test (push) Successful in 1m5s
CI / lint (pull_request) Successful in 50s
docker / lint (pull_request) Successful in 53s
CI / test (pull_request) Successful in 1m1s
docker / test (pull_request) Successful in 1m0s
CI / publish (pull_request) Has been skipped
CI / publish (push) Successful in 55s
docker / docker (push) Failing after 44s
docker / docker (pull_request) Failing after 1m10s
Reviewed-on: #66
2026-07-05 11:46:33 +00:00
Latte 945e40b5d8 fix(ci): smoke-test the image from inside its own container via docker exec
CI / lint (pull_request) Successful in 56s
docker / lint (pull_request) Successful in 54s
CI / test (pull_request) Successful in 1m2s
CI / publish (pull_request) Has been skipped
docker / test (pull_request) Successful in 57s
docker / docker (pull_request) Successful in 41s
The smoke test probed the server over the network, which fails on the
self-hosted runner: the job step runs in a container that shares the host
Docker socket but sits on a different network, so it can reach neither a
published host port (-p lands on the runner host) nor the server
container's bridge IP (different network → curl connect hangs, so the job
looked stuck rather than failing).

Probe from inside the server container with `docker exec` instead: it goes
through the Docker socket and runs 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 image pull is needed. Drop the now
unnecessary -p publish.
2026-07-05 13:42:40 +02:00
Latte ef1567b071 chore(deploy): drop Traefik labels, document Pangolin as the proxy
CI / lint (pull_request) Successful in 53s
docker / lint (pull_request) Successful in 53s
CI / test (pull_request) Successful in 1m3s
CI / publish (pull_request) Has been skipped
docker / test (pull_request) Successful in 57s
docker / docker (pull_request) Failing after 1m7s
The compose service already joins the external `proxy` network and
exposes :8080; the reverse proxy owns TLS and routing. Remove the
Traefik router labels (and the now-unused FAMILIAR_DOMAIN /
TRAEFIK_CERTRESOLVER env vars) and document Pangolin — which discovers
the service on the shared network — as the proxy instead. nginx/Caddy
guidance is unchanged.
2026-07-05 13:38:51 +02:00
Latte 0fdba707fe Merge pull request 'feat: production-ready remote MCP — Docker image, CI publish, per-client docs, OAuth mode' (#64) from feat/docker-serve into dev
CI / lint (push) Successful in 54s
docker / lint (push) Successful in 56s
CI / test (push) Successful in 1m5s
docker / test (push) Successful in 1m3s
CI / publish (push) Successful in 52s
docker / docker (push) Failing after 1m9s
CI / lint (pull_request) Successful in 52s
docker / lint (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m5s
docker / test (pull_request) Successful in 1m5s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Failing after 1m8s
Reviewed-on: #64
2026-07-05 09:54:00 +00:00
Latte df9e06a3e7 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
- 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.
2026-07-04 19:26:33 +02:00
Latte eb2922f63c ci(docker): build, smoke-test, and publish the remote-HTTP image
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.
2026-07-04 19:26:17 +02:00
Latte 39ce8005c0 feat(docker): image, compose, and env for remote-HTTP deployment
- 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.
2026-07-04 19:26:17 +02:00
Latte 7bb99d94bf feat(serve): /healthz, fail-closed bind, and OAuth resource-server mode
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.
2026-07-04 19:25:52 +02:00
Latte 3b2c04cde3 Merge pull request 'feat: serve familiar as a remote HTTP MCP server (bearer auth)' (#63) from feat/remote-http into dev
CI / lint (push) Successful in 50s
CI / test (push) Successful in 58s
CI / publish (push) Successful in 51s
Reviewed-on: #63
2026-07-04 16:46:13 +00:00
Latte ceb907b2fe docs: document the remote-HTTP serve mode, env vars, nginx and Mistral setup
CI / test (pull_request) Successful in 58s
CI / publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 51s
- README + getting-started: a "Serve over HTTPS (remote MCP)" section with the
  `familiar serve` command, the FAMILIAR_HTTP_* env vars, the write-off-by-
  default note, an nginx location block (proxy_buffering off, long read
  timeout), the headless-login / copy-the-store options, and the steps to add
  it as a Mistral custom connector.
- architecture: describe the stdio/HTTP transport split, the bearer-auth gate,
  and the HTTP write default.
- CLAUDE.md: add the new commands and env vars to Commands + Architecture.
2026-07-04 18:22:09 +02:00
Latte 44b2dbac0c test(serve): cover HTTP bearer auth, start refusal, config, write default, headless login
- 401 without/with a bad token (incl. WWW-Authenticate: Bearer), 200 with a
  valid one, driven through the ASGI app in-process.
- configure_http refuses to start when the token is unset or blank.
- FAMILIAR_HTTP_* config defaults and overrides; http_token() strip/empty.
- HTTP write default disabled by default, env still wins, stdio stays enabled.
- parse_callback (URL / bare code / empty) and the manual_login exchange flow.
2026-07-04 18:22:01 +02:00
Latte 458e31685f feat(serve): opt-in HTTP transport with bearer auth + headless login
Add a `familiar serve` subcommand that runs the FastMCP streamable-HTTP
transport for use as a remote MCP server (e.g. a Mistral custom connector),
while stdio stays the default and is completely unchanged.

- config: FAMILIAR_HTTP_HOST/PORT/PATH (default 127.0.0.1:8080/mcp) and a
  live http_token() reader for FAMILIAR_HTTP_TOKEN.
- server: configure_http()/serve_http() attach a StaticTokenVerifier so a
  missing/wrong bearer token gets 401 + WWW-Authenticate: Bearer and a valid
  one passes the handshake. Refuses to start (SystemExit) without a token.
- writes: in HTTP mode the write default flips to disabled unless
  FAMILIAR_ENABLE_WRITES is explicitly truthy; guard() logic is untouched.
- auth: manual_login() + parse_callback() power `login --no-browser`, printing
  the SSO URL and reading the pasted callback from stdin (no local browser).
- bump fastmcp pin to >=3.0.0 for the streamable-HTTP + verifier API.
2026-07-04 18:20:59 +02:00