Compare commits

...
10 Commits
Author SHA1 Message Date
Latte 30036799dc Merge pull request 'docs: document using Gitea as the OAuth issuer' (#70) from docs/gitea-oauth-issuer into dev
CI / lint (push) Successful in 50s
docker / lint (push) Successful in 51s
CI / test (push) Successful in 59s
docker / test (push) Successful in 57s
CI / publish (push) Successful in 51s
docker / docker (push) Successful in 48s
Reviewed-on: #70
2026-07-12 15:57:06 +00:00
Latte 6b9c550d10 docs: document using Gitea as the OAuth issuer
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
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
Latte 21213477d4 Merge pull request 'hello :)' (#69) from chore/documentation into dev
CI / lint (push) Successful in 57s
docker / lint (push) Successful in 57s
CI / test (push) Successful in 1m6s
docker / test (push) Successful in 1m6s
docker / docker (push) Successful in 50s
CI / publish (push) Successful in 52s
Reviewed-on: #69
2026-07-05 16:33:17 +00:00
Latte 5d24ec3687 hello :)
CI / lint (pull_request) Successful in 50s
docker / lint (pull_request) Successful in 53s
CI / test (pull_request) Successful in 1m2s
docker / test (pull_request) Successful in 1m1s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Successful in 42s
2026-07-05 18:30:10 +02:00
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 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
6 changed files with 69 additions and 41 deletions

No files matched your search

+14 -4
View File
@@ -26,10 +26,20 @@ FAMILIAR_HTTP_TOKEN=
# FAMILIAR_ALLOW_INSECURE_BIND=false # FAMILIAR_ALLOW_INSECURE_BIND=false
# --- OAuth 2.1 resource server (only when FAMILIAR_AUTH_MODE=oauth) --------- # # --- OAuth 2.1 resource server (only when FAMILIAR_AUTH_MODE=oauth) --------- #
# familiar only validates JWTs; the login/DCR side lives in an external IdP.
# FAMILIAR_OAUTH_ISSUER=https://idp.example.com/application/o/familiar/ # FAMILIAR_OAUTH_ISSUER=https://idp.example.com/application/o/familiar/
# FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com # this server's public URL # FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com # this server's public URL
# FAMILIAR_OAUTH_AUDIENCE=https://familiar.example.com # defaults to RESOURCE # FAMILIAR_OAUTH_AUDIENCE=https://familiar.example.com # defaults to RESOURCE
# FAMILIAR_OAUTH_JWKS_URI= # optional; else discovered from issuer # FAMILIAR_OAUTH_JWKS_URI= # optional; else discovered from issuer
#
# Using Gitea as the issuer (RS256 JWTs + JWKS + OIDC discovery — config only,
# see doc/deployment.md). Register an OAuth2 app in Gitea first; Gitea puts the
# app's CLIENT ID in the token `aud`, so set AUDIENCE to that, not the resource.
# NOTE: Gitea has no Dynamic Client Registration — works for clients with a
# pre-registered client ID/secret, but NOT the claude.ai web connector (needs DCR).
# FAMILIAR_OAUTH_ISSUER=https://gitea.example.com # no trailing /login/oauth
# FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com
# FAMILIAR_OAUTH_AUDIENCE=<gitea-oauth-app-client-id>
# --- Data / writes --------------------------------------------------------- # # --- Data / writes --------------------------------------------------------- #
# Host path holding the encrypted token store (token.enc + key), audit log, and # Host path holding the encrypted token store (token.enc + key), audit log, and
@@ -40,7 +50,7 @@ FAMILIAR_DATA_DIR_HOST=./data
# Writes off by default on a public instance. Set true to allow guarded writes. # Writes off by default on a public instance. Set true to allow guarded writes.
FAMILIAR_ENABLE_WRITES=false FAMILIAR_ENABLE_WRITES=false
# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # # --- Reverse proxy --------------------------------------------------------- #
# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md. # The container joins the external `proxy` network and exposes :8080; the proxy
FAMILIAR_DOMAIN=familiar.example.com # (Pangolin, or nginx/Caddy — see doc/deployment.md) terminates TLS and forwards
TRAEFIK_CERTRESOLVER=letsencrypt # to it. The public hostname and certs are configured in the proxy, not here.
+14 -6
View File
@@ -52,6 +52,9 @@ jobs:
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
# Per-commit container name: the self-hosted runner shares the host
# Docker daemon, so a fixed name collides across concurrent/re-run jobs.
echo "container=familiar-smoke-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_NAME}" = "main" ]; then if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
else else
@@ -67,8 +70,13 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
IMG="${{ steps.meta.outputs.sha_tag }}" IMG="${{ steps.meta.outputs.sha_tag }}"
CONTAINER="${{ steps.meta.outputs.container }}"
TOKEN="smoke-$(date +%s)" TOKEN="smoke-$(date +%s)"
# Remove any container left behind by a killed/re-run job before we
# reuse the name (shared host daemon → stale containers can survive).
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
# 1. Runs as a non-root user (uid 1000). # 1. Runs as a non-root user (uid 1000).
uid=$(docker run --rm --entrypoint id "$IMG" -u) uid=$(docker run --rm --entrypoint id "$IMG" -u)
echo "container uid: $uid" echo "container uid: $uid"
@@ -83,13 +91,13 @@ jobs:
# socket and runs the probe in the server's own netns, where # socket and runs the probe in the server's own netns, where
# 127.0.0.1:8080 always works regardless of runner topology. The image # 127.0.0.1:8080 always works regardless of runner topology. The image
# ships python + httpx, so no curl or extra pull is needed. # ships python + httpx, so no curl or extra pull is needed.
docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG" docker run -d --name "$CONTAINER" -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG"
# probe "<httpx call returning a Response>" -> prints the status code # probe "<httpx call returning a Response>" -> prints the status code
probe() { docker exec familiar-smoke python -c "import httpx; print(($1).status_code)"; } probe() { docker exec "$CONTAINER" python -c "import httpx; print(($1).status_code)"; }
for i in $(seq 1 30); do for i in $(seq 1 30); do
if docker exec familiar-smoke python -c \ if docker exec "$CONTAINER" python -c \
"import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then
break break
fi fi
@@ -98,7 +106,7 @@ jobs:
echo "== /healthz (expect 200) ==" echo "== /healthz (expect 200) =="
code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)") code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; }
echo "== MCP without token (expect 401) ==" echo "== MCP without token (expect 401) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})") code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})")
@@ -106,7 +114,7 @@ jobs:
echo "== MCP with valid token (expect 200) ==" echo "== MCP with valid token (expect 200) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'ci','version':'1'}}})") code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'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; } [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs "$CONTAINER"; exit 1; }
echo "Image smoke-test passed." echo "Image smoke-test passed."
@@ -135,7 +143,7 @@ jobs:
if: always() if: always()
shell: bash shell: bash
run: | run: |
docker rm -f familiar-smoke || true docker rm -f ${{ steps.meta.outputs.container }} || true
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
docker image prune -f || true docker image prune -f || true
docker builder prune -f || true docker builder prune -f || true
+5 -1
View File
@@ -22,13 +22,15 @@ uv run familiar login # browser EVE SSO login (adds a character; run onc
uv run familiar login --no-browser # headless login: paste the callback URL on stdin uv run familiar login --no-browser # headless login: paste the callback URL on stdin
uv run familiar status # list logged-in characters uv run familiar status # list logged-in characters
uv run familiar writes disable # block write actions (default: enabled on stdio, disabled on HTTP) uv run familiar writes disable # block write actions (default: enabled on stdio, disabled on HTTP)
uv run familiar sde download # download + install the local SDE for offline name/type lookups
uv run familiar sde status # is the local SDE present, and where (also: sde path)
``` ```
There is no separate build step; `uv build` produces the wheel (CI does this on merge). There is no separate build step; `uv build` produces the wheel (CI does this on merge).
## Architecture ## Architecture
**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, sde, 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 (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. **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.
@@ -41,6 +43,8 @@ Either mode calls `writes.set_http_context(True)`, flipping the **write default
**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`.
**Name resolution & offline SDE.** Callers pass human names ("Tritanium", "Jita"), not numeric IDs. [lookup.py](src/familiar/lookup.py) `resolve_name(name, *categories)` resolves them against ESI's `/universe/ids/`. [hubs.py](src/familiar/hubs.py) hardcodes the major NPC trade hubs (Jita/Amarr/Dodixie/Rens/Hek) with their stable region/system/station IDs so the common "price in Jita" case skips a lookup. [sde.py](src/familiar/sde.py) is an **optional** local SQLite copy of EVE's Static Data Export (path from `FAMILIAR_SDE_PATH`, else `data_dir()/sde.sqlite`) that backs offline name/type resolution; every accessor degrades gracefully — `sde.available()` returns `False` when the file or a needed table is absent, and callers fall back to ESI. `sde` also registers one tool (`sde_status`); the `familiar sde` CLI downloads/installs it.
**Auth is multi-character.** [auth.py](src/familiar/auth.py) does EVE SSO PKCE (no client secret), stores a **dict of characters keyed by id** in one Fernet-encrypted file (`load_store`/`_save_character`/`list_characters`/`remove_character`), and `get_auth(character_id)` returns a valid access token, refreshing via the rotating refresh token when near expiry. The legacy single-character file is migrated on load. Config (client id, callback `localhost:8765`, the 73 read+write scopes split into `READ_SCOPES`/`WRITE_SCOPES`) lives in [config.py](src/familiar/config.py), all env-overridable. **Auth is multi-character.** [auth.py](src/familiar/auth.py) does EVE SSO PKCE (no client secret), stores a **dict of characters keyed by id** in one Fernet-encrypted file (`load_store`/`_save_character`/`list_characters`/`remove_character`), and `get_auth(character_id)` returns a valid access token, refreshing via the rotating refresh token when near expiry. The legacy single-character file is migrated on load. Config (client id, callback `localhost:8765`, the 73 read+write scopes split into `READ_SCOPES`/`WRITE_SCOPES`) lives in [config.py](src/familiar/config.py), all env-overridable.
**Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these. **Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these.
+2
View File
@@ -4,6 +4,8 @@
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. 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.
> **Gitea won't work here.** Gitea is a fine OAuth issuer for familiar in general (see [deployment.md → Using Gitea as the OAuth issuer](../deployment.md)), but it has **no** Dynamic Client Registration, which this connector requires. Use it only with clients that take a pre-registered client ID/secret, or put a DCR-capable IdP 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. > **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) ## 1. Set up the IdP (external)
+25 -11
View File
@@ -20,7 +20,26 @@ Once it's running, see [doc/clients/](clients/) for exact per-client setup.
`FAMILIAR_AUTH_MODE` picks how the HTTP transport authenticates: `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. - **`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). - **`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](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:
```bash
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](clients/claude-web.md).
## Environment variables ## Environment variables
@@ -77,8 +96,8 @@ Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret `
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), FAMILIAR_DOMAIN, # edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32),
# cert resolver, FAMILIAR_IMAGE (:latest prod / :dev dev channel) # FAMILIAR_IMAGE (:latest prod / :dev dev channel)
docker network create proxy # once, if your reverse proxy doesn't own it yet docker network create proxy # once, if your reverse proxy doesn't own it yet
@@ -121,16 +140,11 @@ familiar.yourdomain.tld {
Caddy fetches and renews the certificate automatically and streams by default. Caddy fetches and renews the certificate automatically and streams by default.
### Traefik (labels included) ### Pangolin
`docker-compose.yml` already carries Traefik router labels. Set in `.env`: 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).
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 ## Operational notes
+9 -19
View File
@@ -46,8 +46,8 @@ services:
cap_drop: cap_drop:
- ALL - ALL
user: "1000:1000" user: "1000:1000"
networks: # networks:
- proxy # - proxy
healthcheck: healthcheck:
test: test:
[ [
@@ -60,21 +60,11 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
labels: # No proxy labels: Pangolin discovers this service on the shared `proxy`
# --- Traefik TLS termination (Let's Encrypt) -------------------------- # # network and forwards to :8080 — configure the route in Pangolin, not here.
# 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: # networks:
# Shared reverse-proxy network (Traefik/Caddy). Create once with: # # Shared reverse-proxy network (Pangolin). Create once with:
# docker network create proxy # # docker network create proxy
proxy: # proxy:
external: true # external: true