Files
Latte 6b9c550d10
CI / lint (pull_request) Successful in 51s
CI / test (pull_request) Successful in 58s
docker / lint (pull_request) Successful in 49s
CI / publish (pull_request) Has been skipped
docker / test (pull_request) Successful in 59s
docker / docker (pull_request) Successful in 1m10s
docs: document using Gitea as the OAuth issuer
familiar is issuer-agnostic (resource server only), so Gitea can back
oauth mode with config alone: RS256 JWTs, JWKS at /login/oauth/keys, and
OIDC discovery. Add a deployment.md section covering the env config, the
aud=client-ID audience pitfall, and the no-DCR caveat; add a Gitea block
to .env.example; and note in claude-web.md that Gitea can't back the
claude.ai web connector (which requires DCR).
2026-07-12 17:52:26 +02:00

9.4 KiB

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.
  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/ 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 (DCR) — the authorization side lives in an external IdP (Authentik, Keycloak, Gitea, …). Only needed for OAuth-only clients such as the native Claude web connector. See doc/clients/claude-web.md.

Using Gitea as the OAuth issuer

Gitea can act as the IdP for oauth mode — no code change, config only. Gitea is already an OIDC provider: it signs access tokens as RS256 JWTs (default), publishes its keys at https://gitea.example.com/login/oauth/keys, and serves OIDC discovery at https://gitea.example.com/.well-known/openid-configuration, so familiar's JWTVerifier can validate its tokens.

  1. In Gitea, Settings → Applications → Create OAuth2 application (or admin-wide under Site Administration → Applications). Set the redirect URI(s) your MCP client needs. Note the generated client ID and secret.

  2. Point familiar at Gitea:

    FAMILIAR_AUTH_MODE=oauth
    FAMILIAR_OAUTH_ISSUER=https://gitea.example.com          # no trailing /login/oauth
    FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com     # this server's public URL
    FAMILIAR_OAUTH_AUDIENCE=<gitea-oauth-app-client-id>      # Gitea sets aud to the client ID, not the resource URL
    # FAMILIAR_OAUTH_JWKS_URI is discovered from the issuer's OIDC metadata; leave unset.
    

    FAMILIAR_OAUTH_AUDIENCE matters: Gitea puts the OAuth application's client ID in the token's aud, which differs from familiar's default (the resource URL). If you get invalid token / audience mismatch, decode a real access token (jwt.io) and set FAMILIAR_OAUTH_AUDIENCE to whatever its aud actually is.

Caveat — no DCR. Gitea does not support Dynamic Client Registration. Clients that let you paste a pre-registered client ID/secret (Claude Desktop/Code, VS Code, your own scripts) work fine with Gitea. The claude.ai web custom connector requires DCR, so Gitea alone can't back it — you'd need a DCR-capable IdP (Authentik/Keycloak) or a DCR proxy in front of Gitea. See doc/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.

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:

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

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

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.