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).
9.4 KiB
Deployment — familiar as a remote MCP server
familiar runs two ways from the same code and token store:
- Local (stdio) — the default. How Claude Desktop / Claude Code / Cowork launch it. Nothing here changes; see getting-started.
- 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 barefamiliar) starts HTTP. - Loopback bind by default (
127.0.0.1). The Docker image binds0.0.0.0so 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 explicitFAMILIAR_ALLOW_INSECURE_BIND=trueescape 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 inFAMILIAR_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.
-
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.
-
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_AUDIENCEmatters: Gitea puts the OAuth application's client ID in the token'saud, which differs from familiar's default (the resource URL). If you get invalid token / audience mismatch, decode a real access token (jwt.io) and setFAMILIAR_OAUTH_AUDIENCEto whatever itsaudactually 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_TOKENand restarting; update every client header. - Restrict inbound at the proxy/firewall to the clients you expect.
- Watch
docker compose logsfor401auth failures and/data/audit.logfor the write trail. - Re-login (host) whenever you change requested scopes, then refresh the
/datatoken store.