ADR-0033 — UI-settable MCP access token + "allow without token" (loopback/private only); hashed storage in vault_state, public source always requires the token¶
- Status: Proposed (owner decision pending on two points — §7) — DESIGN ONLY, no implementation
- Date: 2026-06-30
- Sprint: v0.5 (Feature — adopt the nashsu/llm_wiki auth UX: token configurable from Settings + an "allow access without a token" option)
- Feature: F1-MCP-UI (Amendment — owner request, Emanuele Chiummo) · supersedes the env-only token parts of ADR-0029 §2.2 and integrates with ADR-0032 (runtime toggle) · reconciles ADR-0008 / CLAUDE.md §12 ("no secrets in DB")
- Invariants owned: I6 (nothing hardcoded — no provider touched; mount path + IP ranges are named constants) · I9 (reuse
vault_state+ the existing ADR-0029 mount + existing FastMCP server; NO second process, NO new gateway, NO new tool, NO broadened tool set) · I3 (UI: a token field + a switch + one PUT — local state, no store churn) · I1/I5 (vault-write path unchanged —write_pagestill routes throughwrite_wiki_page) · I7 (no new loop) - Author: solution-architect
- Implementers: backend-engineer (column + migration
0012+ token hashing + middleware source-trust gate +PUT /mcp/auth+/mcp/infofields) · frontend-engineer (token field + "allow without token" switch + one-time-token reveal + i18n) · tech-writer (D2 ER regen, D4 OpenAPI, D6b deploy note, D5 screenshot) · devops-engineer (one-line migration note; confirm the tunnel setsCF-Connecting-IP/ trusted-proxy posture)
1. Context¶
ADR-0029 shipped the remote MCP surface as an env-only fail-closed design:
MCP_AUTH_TOKEN (env, no default) both (a) decides whether the FastMCP Streamable-HTTP
sub-app is mounted at all (main.py:367 — if _http_mcp_asgi_app is not None and
settings.mcp_auth_token is not None) and (b) is the bearer secret enforced by
_BearerAuthMiddleware (constant-time hmac.compare_digest, main.py:225). ADR-0032 added a
persisted vault_state.remote_mcp_enabled runtime flag (Alembic 0011) that 404-gates requests
before the bearer check, with a token-floor clamp on PUT /mcp/remote.
Owner request (locked): adopt the nashsu/llm_wiki auth UX — only the auth parts:
- The access token is configured from the UI (Settings → API + MCP), not env-only.
- There is an "Allow access without a token" option; when on, a client may connect unauthenticated.
- Security posture from the reference: bind loopback by default, file reads via allow-list, never pass tokens on the CLI.
Explicitly NOT adopted (owner): separate "Enable HTTP API" / "Enable MCP access" toggles, a broader tool set, the separate-process proxy. KEEP: in-process FastMCP, the 4 tools, the ADR-0032 runtime toggle.
The deployment reality that makes "allow without token" dangerous. The owner reaches the
single FastAPI origin over two networks (CLAUDE.md §1): Tailscale mesh (private, CGNAT
100.64.0.0/10) and Cloudflare Tunnel (public internet). "Allow without token" is benign on
loopback / Tailscale but is a vault-mutating-capable open endpoint on the public internet.
The reference model assumes a 127.0.0.1 bind; we cannot assume that, because the same origin is
deliberately published publicly. Therefore the "allow without token" option must be scoped to
non-public request sources — this is the load-bearing security decision of this ADR (§2.3).
The ADR-0008 tension. ADR-0008 and CLAUDE.md §12 say "no secrets in code or database; API keys are environment-only." The owner now wants a UI-settable token, which implies the token is created/stored by the app, not pasted into env. §2.1 resolves this tension precisely.
2. Decision¶
2.1 The MCP access token is an APP-ISSUED ACCESS CREDENTIAL — exempt from ADR-0008's "provider keys" rule; stored as a SALTED HASH in vault_state¶
Reconciliation with ADR-0008 / §12 (the key item). ADR-0008's "no secrets in DB" rule
governs third-party provider API keys (Anthropic key, OpenAI-compatible key) — credentials
that authenticate Synapse to an external service. Its rationale is: those keys are
high-blast-radius bearer secrets for someone else's billed API, and leaking the DB must not
leak them; env keeps them out of backups/git/pg_dump.
The MCP access token is categorically different: it is a credential Synapse issues to authenticate callers to itself (like a session token or an app API key), scoped to one local surface, revocable in one click, with blast radius = "read/maybe-write this one vault." It is not a third-party provider key. Decision: the MCP access token is exempt from ADR-0008's provider-key rule — but we still honour the spirit of §12 (never store a recoverable secret in the DB) by storing only a salted hash, never the plaintext.
Storage decision — SALTED HASH, never plaintext (strongly weighted for security):
- Add
vault_state.mcp_access_token_hash : Mapped[str | None](Text, nullable, defaultNULL). Stores a salted, slow-ish KDF hash of the token (PBKDF2-HMAC-SHA256 via stdlibhashlib.pbkdf2_hmac, orargon2/bcryptif already vendored — no new heavy dep if avoidable; PBKDF2 from stdlib is sufficient and dependency-free). Encoded aspbkdf2_sha256$<iterations>$<salt_b64>$<hash_b64>so the verifier is self-describing. - The token plaintext is shown exactly ONCE, at generation time, in the
PUT /mcp/authresponse (or a dedicated rotate response). It is never re-displayed and never returned by any GET./mcp/inforeports onlytoken_configured: bool(derivedmcp_access_token_hash IS NOT NULL), never the value or the hash. - Why hash, not re-displayable plaintext: a re-displayable token requires storing a recoverable secret in Postgres — exactly what §12 forbids in spirit, and it makes a DB dump / backup leak the live credential. A salted hash means a DB leak yields no usable token (the surface stays closed). The cost — the owner cannot "look up" a lost token and must rotate instead — is acceptable and is in fact the more secure UX (matches "shown once" in many app token UIs). This is the recommended option and the ADR builds it.
- Constant-time verification: the middleware computes the candidate's PBKDF2 hash with the
stored salt+iterations and compares with
hmac.compare_digestagainst the stored hash. (PBKDF2 is intentionally slow; the per-request cost is a one-time KDF on the presented token only when a token is required — acceptable for a low-QPS personal MCP surface. If KDF latency is ever a concern, an in-process verified-token cache keyed by a fast hash of the bearer can be added; not needed at v0.5 volumes.)
Env MCP_AUTH_TOKEN stays as a BOOTSTRAP fallback (not removed).
- If vault_state.mcp_access_token_hash is set, the DB hash is authoritative (the UI-set
token wins).
- Else if settings.mcp_auth_token (env) is set, it is the bootstrap token: the middleware
verifies the bearer against the env value (plaintext compare, constant-time — unchanged
ADR-0029 path). This preserves every existing deployment and lets the owner seed a token before
the UI is reachable.
- Else no token is configured (and "allow without token" governs reachability — §2.3).
- This makes migration zero-friction: existing MCP_AUTH_TOKEN users keep working untouched;
the UI token is purely additive and, once set, takes precedence.
Migration: YES. New revision 0012_vault_state_mcp_access_token_hash —
ALTER TABLE vault_state ADD COLUMN mcp_access_token_hash TEXT NULL. D2/ER MUST regenerate
(make er) and match the live schema (I8). This is the one and only schema change in this ADR.
Default NULL ⇒ on upgrade, no UI token exists yet ⇒ env-or-none behaviour holds (no posture
change on upgrade).
2.2 Scope — the token gates the MCP REMOTE surface (/mcp/server) ONLY; the rest of the API stays unauthenticated¶
This is confirmed and explicit: the token gates only the /mcp/server sub-app, exactly as
ADR-0029 scoped _BearerAuthMiddleware. The same-origin React web UI and the rest of the
REST API stay unauthenticated (ADR-0028 relative base; the browser sends no auth). Adding
auth to the whole API would break the browser UI and is out of scope.
- The MCP stdio server (
python -m app.mcp.server, launched locally by Claude Desktop) is unchanged and unauthenticated — it is local-only by construction (ADR-0010). - An authenticated external REST API path is NOT built here. (Open question O3 §7 records it if the owner ever wants it; default = MCP-surface-only.)
2.3 "Allow without token" is permitted ONLY for non-public request sources; a public source ALWAYS requires the token (CRITICAL)¶
This is the load-bearing security decision. We classify every /mcp/server request as PRIVATE
or PUBLIC and let "allow without token" relax auth only for PRIVATE.
A request is PUBLIC (token always required, regardless of allow_without_token) if ANY of:
- It carries a Cloudflare edge header: CF-Connecting-IP or CF-Ray is present. The
owner's public path is Cloudflare Tunnel; cloudflared injects these on every edge request.
Their presence is a positive, hard signal of "this came through the public tunnel."
- The resolved source IP (see trust model below) is not in a private/trusted range.
A request is PRIVATE (eligible for token-less access when allow_without_token is ON) only if
BOTH:
- It carries no Cloudflare edge header (CF-Connecting-IP / CF-Ray absent), AND
- The resolved source IP is in a trusted private range (allow-list):
- loopback 127.0.0.0/8, ::1
- Tailscale CGNAT 100.64.0.0/10
- RFC1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
- link-local 169.254.0.0/16, fe80::/10 (and ULA fc00::/7 for IPv6 private)
Source-IP trust model (X-Forwarded-For spoofing — addressed):
- Default and fail-safe: use the transport peer IP (scope["client"][0] — the actual TCP
peer ASGI reports). Do NOT trust X-Forwarded-For by default, because a public client can
forge it.
- X-Forwarded-For / Forwarded are honoured only when the immediate peer is a configured
trusted proxy (MCP_TRUSTED_PROXIES, env, default empty ⇒ XFF ignored entirely). When
the peer is trusted, take the last XFF hop that the trusted proxy appended (the
proxy-attested client), not the leftmost (client-controlled) entry.
- CF-Connecting-IP is treated as a PUBLIC signal, never as a trust grant. Its mere
presence forces PUBLIC (token required). We never use it to grant private access. So even if
an attacker forges CF-Connecting-IP, the only effect is to make their request more
restricted (PUBLIC), never less — fail-safe by construction.
- Fail-safe default = PUBLIC (require token) when uncertain. If the source cannot be resolved,
is ambiguous, or any classification step is unsure, the request is treated as PUBLIC and the
token is required. Token-less access is granted only on a positive PRIVATE determination.
Net effect: "Allow without token" is a convenience for loopback / LAN / Tailscale, and is
structurally incapable of opening the surface to the Cloudflare-tunnel public path. The worst
an allow_without_token=ON posture can do over the public tunnel is… nothing — the public path
still demands the token (or 404s if no token is configured — §2.4 row).
Binding note (defence-in-depth, ops): the reference's "bind 127.0.0.1" is an ops hardening,
not enforceable in-app here because the surface is mounted in the shared public FastAPI origin
(I9 — one origin, one tunnel). We compensate at the application layer with the source-trust gate
above. D6b documents that the owner MAY additionally put Cloudflare Access in front of
/mcp/server for edge-enforced auth (pure-ops, no code) — the preferred extra layer.
2.4 Mount/gate integration — the gate is the sole arbiter; mount whenever the surface could be reachable (no remount)¶
Structural change from ADR-0029/0032. Today the sub-app is mounted only if a token is set
(main.py:367). With "allow without token," the surface must be reachable without a token, so
the mount condition can no longer be "token set." We move to: build + mount the sub-app once
whenever the MCP HTTP capability is compiled in; the middleware decides per-request (still
no remount — ADR-0032 §2.3 stands).
- Mount condition (new): build
_http_mcp_asgi_appand mount it underMCP_MOUNT_PATHunconditionally at startup (the FastMCP lifespan/session-manager starts once, as ADR-0032 requires). The middleware now carriestoken | None, theRemoteMcpFlag, theallow_without_tokenflag, and the source-classifier. _BearerAuthMiddlewareis extended (renamed conceptually to the MCP access gate) to the decision table below. Order: runtime-flag check → source classification → token/allow logic. Lifespan/WS scopes pass through unconditionally (unchanged).
Decision table (HTTP request to /mcp/server; remote_enabled = ADR-0032 flag;
token_configured = DB hash set or env bootstrap set; allow = allow_without_token):
remote_enabled |
source | token_configured |
allow |
bearer presented | Result |
|---|---|---|---|---|---|
| OFF | any | any | any | any | 404 (ADR-0032 — indistinguishable from not-mounted) |
| ON | any | any | any | valid token | PASS (a valid token always works) |
| ON | PRIVATE | yes | OFF | none/bad | 401 (token required; private but allow is off) |
| ON | PRIVATE | yes | ON | none | PASS (token-less allowed on private source) |
| ON | PRIVATE | no | ON | none | PASS (open on private source — no token exists, allow is on) |
| ON | PRIVATE | no | OFF | none | 404 (no token AND allow off ⇒ surface effectively closed; 404 not 401 to avoid the "protected thing exists" tell when nothing can authenticate) |
| ON | PUBLIC | yes | any | none/bad | 401 (public ALWAYS requires the token — §2.3) |
| ON | PUBLIC | no | any | any | 404 (public, no token can ever be presented ⇒ closed; never open the public surface token-lessly even if allow=ON) |
Key rows restated for emphasis:
- (b) allow ON + PUBLIC source ⇒ STILL 401/404 — allow_without_token is ignored for public
sources (§2.3). This is the whole point.
- (d) remote_enabled OFF ⇒ 404 regardless of everything — ADR-0032 floor preserved.
- The token-floor clamp of ADR-0032 §2.4 is REPLACED by an allow-aware floor (§2.5):
remote_enabled=ON is now permitted when either a token is configured or
allow_without_token=ON (because a token-less private posture is a legitimate, owner-chosen
configuration). Without either, enabling remote is pointless (the surface 404s for everyone),
so we clamp remote_enabled to OFF (preserving the "no misleading ON" property).
2.5 Endpoints — PUT /mcp/auth (set/rotate token + allow flag); ADR-0032 PUT /mcp/remote retained with an allow-aware clamp¶
New: PUT /mcp/auth — sets/rotates the token and the allow-without-token flag.
PUT /mcp/auth
request body: McpAuthRequest {
rotate_token: bool | None, # true ⇒ generate a new random token, store its hash, return plaintext ONCE
token: str | None, # OR set an explicit token (owner-supplied); stored as hash, NOT echoed
clear_token: bool | None, # true ⇒ delete the stored token (hash → NULL)
allow_without_token: bool | None # set the allow flag; omitted ⇒ unchanged
}
response body: McpAuthStateResponse {
token_configured: bool, # hash set OR env bootstrap present
token_source: "db" | "env" | "none",
allow_without_token: bool,
remote_enabled: bool, # post-clamp (§2.4)
mount_path: str, # "/mcp/server"
generated_token: str | None # ONLY populated when rotate_token=true — shown ONCE, never again
}
Semantics:
- rotate_token=true: server generates a high-entropy token (secrets.token_urlsafe(32)),
stores pbkdf2_sha256$... in mcp_access_token_hash, and returns it once in
generated_token. Subsequent GET/PUT never return it.
- token=<value>: owner supplies their own token; server stores only its hash;
generated_token stays null (owner already knows it).
- clear_token=true: set hash NULL. If this leaves token_configured=false and
allow_without_token=false, the remote_enabled clamp may force it OFF (§2.4).
- allow_without_token: persisted (new column/field — §3); if turning allow=OFF would leave
no token AND remote ON, remote_enabled is clamped OFF and reported.
- Plaintext token is NEVER stored and NEVER returned except the one-time generated_token.
- Auth on the endpoint itself: same-origin / unauthenticated, consistent with ADR-0032
§2.4 (the network perimeter + the bearer floor are the gates; the worst an unauthenticated
flip can do is expose a token-protected surface, or — for allow_without_token — a
private-source-only surface, never a public-open one). Stated trade-off identical to ADR-0032
§2.4; edge auth (Cloudflare Access) is the pure-ops hardening path. However, because
allow_without_token is a more sensitive flip than the plain toggle, this is flagged as
owner-decision O2 (§7): hard-block public-open by design (chosen here) vs. add per-route auth
on /mcp/auth.
Retained: PUT /mcp/remote (ADR-0032 §2.4) — unchanged shape, but its clamp becomes
allow-aware (§2.4): enabling is permitted when token_configured OR allow_without_token.
/mcp/info additions (McpInfoResponse, backend main.py + frontend providerClient.ts):
| Field | Type | Source | Meaning |
|---|---|---|---|
token_configured |
bool | mcp_access_token_hash IS NOT NULL OR bool(env token) |
retained; now true for DB-or-env token. Never the value. |
token_source |
"db"\|"env"\|"none" |
precedence resolver (§2.1) | which token is authoritative (lets UI say "set in UI" vs "from env"). |
allow_without_token |
bool | persisted flag (§3) | the new option's state. |
remote_enabled |
bool | RemoteMcpFlag |
unchanged (ADR-0032). |
mount_path |
str | MCP_MOUNT_PATH constant |
unchanged (I6). |
http_enabled / remote_write_enabled unchanged. No token, no hash, no salt is ever returned
by any GET. (http_enabled was bool(env token); it becomes token_configured semantics —
retained as alias for backward compat, ADR-0032 §2.5.)
2.6 UI (I3 — no heavy render)¶
SectionApiMcp in SettingsPanel.tsx gains a small Access sub-block (local component state +
fetch/PUT only — no Zustand, mirrors ADR-0027/0032):
- A token control: "Generate token" (calls PUT /mcp/auth {rotate_token:true}, then shows the
returned generated_token once in a copy-once reveal with a "you won't see this again"
warning), "Rotate", and "Clear". When a token exists, show only token_configured=true +
token_source — never the value.
- An "Allow access without a token" switch bound to allow_without_token. When ON, render an
inline security caveat (i18n) explaining it applies to private sources only (loopback /
LAN / Tailscale) and that the public tunnel still requires the token.
- The existing ADR-0032 remote toggle + URL row stays; its three-state display now also honours
allow_without_token (remote can be ON with a token-less private posture).
- i18n keys under settings.apiMcp.access.* in en.json + it.json (parity gate). The token
value and hash are never i18n keys.
3. New config / schema / migration¶
| Kind | Name | Type / default | Where | Notes |
|---|---|---|---|---|
| DB column | vault_state.mcp_access_token_hash |
TEXT NULL |
models.py VaultState |
NEW — Alembic 0012; D2/ER MUST regenerate. Salted PBKDF2 hash; never plaintext. |
| DB column | vault_state.mcp_allow_without_token |
BOOLEAN NOT NULL DEFAULT false |
models.py VaultState |
NEW (same migration 0012). Persisted "allow without token" flag. Default OFF (fail-closed). |
| migration | 0012_vault_state_mcp_access_token_and_allow |
add 2 columns | backend/alembic/versions/ |
Run by standard alembic upgrade head. D2/ER regen (I8). |
| env (retained) | MCP_AUTH_TOKEN |
secret str, no default | config.py |
Now a BOOTSTRAP fallback (§2.1): used iff DB hash is NULL. Existing deployments keep working. |
| env (new) | MCP_TRUSTED_PROXIES |
comma-list of CIDRs, default empty | config.py |
Peers whose X-Forwarded-For is trusted (§2.3). Empty ⇒ XFF ignored (fail-safe). |
| constants | MCP_PRIVATE_CIDRS |
tuple of CIDRs | main.py/module |
loopback/CGNAT/RFC1918/link-local/ULA (§2.3). Named constant (I6 — no scattered literals). |
| route | PUT /mcp/auth |
McpAuthRequest → McpAuthStateResponse |
main.py |
Set/rotate/clear token + allow flag. One-time generated_token. |
| route (retained) | PUT /mcp/remote |
unchanged shape | main.py |
Clamp becomes allow-aware (§2.4). |
| response fields | McpInfoResponse.{token_source, allow_without_token} (+ retained token_configured) |
str, bool | main.py + providerClient.ts |
Token/hash NEVER returned. |
| derived | settings.mcp_http_enabled |
bool | config.py |
Now True unconditionally (capability compiled in) OR derived from "token-or-allow can ever apply" — implementer aligns with the new always-mount (§2.4). The reachability is decided by the gate, not by mount. |
One migration, two columns. Flagged for the docs gate: make er + make openapi + D5/D6b.
4. Per-agent file ownership¶
backend-engineer:
- backend/app/models.py — add mcp_access_token_hash (Text, nullable) + mcp_allow_without_token (Bool, default false) to VaultState.
- backend/alembic/versions/0012_vault_state_mcp_access_token_and_allow.py — add the two columns; downgrade drops them.
- backend/app/config.py — keep mcp_auth_token as bootstrap; add mcp_trusted_proxies parse; align mcp_http_enabled with always-mount (§2.4/§3).
- backend/app/main.py —
- token hashing/verification helpers (PBKDF2 via stdlib hashlib; secrets.token_urlsafe for generation; hmac.compare_digest on the hash);
- precedence resolver db hash → env → none + token_source;
- source classifier (peer-IP + CF-header → PRIVATE/PUBLIC; XFF only via MCP_TRUSTED_PROXIES; fail-safe PUBLIC) — MCP_PRIVATE_CIDRS named constant;
- extend the MCP access gate (formerly _BearerAuthMiddleware) to the §2.4 decision table; always-mount the sub-app (drop the token-conditional mount at main.py:367); keep no-remount;
- PUT /mcp/auth + McpAuthRequest/McpAuthStateResponse (one-time generated_token; never echo otherwise);
- make PUT /mcp/remote clamp allow-aware;
- add token_source, allow_without_token to McpInfoResponse + get_mcp_info; load mcp_allow_without_token into the in-process posture at startup (alongside RemoteMcpFlag).
- backend/tests/ — decision-table tests (every row of §2.4); PRIVATE vs PUBLIC classification (loopback/CGNAT/RFC1918 ⇒ PRIVATE; CF-Connecting-IP/CF-Ray present ⇒ PUBLIC even from a private peer; forged XFF ignored when peer untrusted; forged CF-Connecting-IP only ever restricts); token hash round-trip + constant-time verify; no token/hash in any response (grep); env-bootstrap precedence; clamp behaviour.
frontend-engineer:
- frontend/src/api/providerClient.ts — extend McpInfoResponse (token_source, allow_without_token); add McpAuthRequest/McpAuthStateResponse + setMcpAuth().
- frontend/src/components/settings/SettingsPanel.tsx — SectionApiMcp Access sub-block: generate/rotate/clear token, one-time reveal, "allow without token" switch + security caveat; local state only (I3).
- frontend/src/i18n/en.json, it.json — settings.apiMcp.access.* (parity).
- frontend/src/tests/ — vitest: token never rendered when configured; generated_token shown once; allow-switch caveat present; PUT shapes.
tech-writer:
- docs/er/schema.mmd — make er (D2; two new columns).
- docs/api/openapi.json — make openapi (D4; PUT /mcp/auth, new /mcp/info fields).
- docs/DEPLOY.md (D6b) — token now UI-settable (env = bootstrap fallback); "allow without token" is private-source-only, public tunnel always needs the token; MCP_TRUSTED_PROXIES guidance; recommend optional Cloudflare Access on /mcp/server.
- docs/screens/settings-api-mcp.png — refresh via Playwright (D5).
- Amend ADR-0029 (§2.2) and ADR-0032 (§2.4 clamp): add a "Superseded in part by ADR-0033" note to each (token now DB-hash-with-env-bootstrap; clamp now allow-aware). One-line cross-refs only — do not rewrite them.
devops-engineer: one-line note — migration applied by standard alembic upgrade head; verify cloudflared sets CF-Connecting-IP on tunnel requests (it does by default) and document the MCP_TRUSTED_PROXIES posture (default empty = trust only the transport peer). No new service/port/env beyond MCP_TRUSTED_PROXIES.
5. Acceptance checks (DoD)¶
- Token UI round-trip.
PUT /mcp/auth {rotate_token:true}returnsgenerated_tokenonce; a subsequent bearer with that token reaches the tools;/mcp/infoshowstoken_configured=true,token_source="db", and never the token/hash. A second GET/PUT never re-returns the value. - Hash storage, no plaintext.
mcp_access_token_hashis a PBKDF2 string;grep/DB dump shows no plaintext token anywhere; verification is constant-time. - Env bootstrap precedence. With DB hash NULL and
MCP_AUTH_TOKENset, the env token authenticates (token_source="env"). AfterPUT /mcp/authsets a DB token, the DB token wins (token_source="db"); the env token no longer authenticates. - PRIVATE allow-without-token works.
remote_enabled=ON,allow_without_token=ON, no token, request from loopback/CGNAT/RFC1918 peer with no CF header ⇒ PASS (tools reachable). - PUBLIC always requires token (CRITICAL). Same posture but the request carries
CF-Connecting-IP(orCF-Ray), or comes from a non-private peer ⇒ 401 (if a token is configured) or 404 (if none) — token-less PUBLIC access is impossible. Verified for both "CF header present from a private peer" and "non-private peer, no CF header." - XFF spoof ignored. A request from an untrusted peer with a forged
X-Forwarded-For: 127.0.0.1is classified by the peer IP (PUBLIC) ⇒ token required. With a peer listed inMCP_TRUSTED_PROXIES, the proxy-attested last hop is used. - CF-header forge only restricts. A request with a forged
CF-Connecting-IPis forced PUBLIC (never granted private access) — the forge cannot relax auth. - Decision table. Every row of §2.4 has a passing test, including
remote_enabled=OFF ⇒ 404(ADR-0032 floor) andno token + allow OFF ⇒ 404. - Allow-aware clamp.
PUT /mcp/remote {enabled:true}succeeds whenallow_without_token=ONeven with no token (private-open posture); clears to OFF when neither token nor allow is set. - No remount / session manager stable. Flipping any flag never restarts the StreamableHTTP session manager (mounted+started once — ADR-0032 §2.3).
- I1/I5 unchanged.
grepproves HTTPwrite_page(when exposed) still routes throughwrite_wiki_page. - Docs gate.
make ermatches live schema (two new columns);make openapiincludesPUT /mcp/auth+ new fields; D5 refreshed; ADR-0029/0032 amendment notes added (I8).
6. Consequences¶
Positive — the owner gets the nashsu/llm_wiki auth UX (UI-settable token + "allow without
token") without weakening the public surface: token-less access is structurally impossible over
the Cloudflare tunnel (§2.3 fail-safe). The token is stored as a salted hash (a DB/backup leak
yields no usable credential), honouring §12's spirit while correctly exempting an app-issued access
credential from ADR-0008's provider-key rule. Env MCP_AUTH_TOKEN stays as a bootstrap ⇒ zero
breakage for existing deployments. Reuses vault_state + the existing mount + FastMCP — no second
process, no new tool, no broadened surface (I9). No remount (ADR-0032 §2.3 stands).
Trade-offs (explicit) —
- One migration, two columns (the only schema change); D2/ER regen in the docs gate.
- Lost tokens cannot be recovered — only rotated (hash storage). This is the more secure UX and
is intentional; the UI says so.
- Per-request PBKDF2 on the presented token when a token is required — negligible at personal
MCP volumes; an in-process verified-token cache is a documented escape hatch if ever needed.
- allow_without_token is owner-settable from an unauthenticated same-origin endpoint — but it
can only ever open a private-source-only posture (public stays token-gated), so the worst case
is bounded. Edge auth (Cloudflare Access) remains the pure-ops hardening path. See O2 (§7).
- Source classification depends on CF-Connecting-IP/peer-IP correctness. Mitigated by the
fail-safe-PUBLIC default and by never trusting XFF or CF headers to grant access (only to
restrict). If the owner runs a different public proxy that does not set CF headers, they must
ensure that proxy is NOT in MCP_PRIVATE_CIDRS and is reached such that the peer IP is public
(documented in D6b).
Invariant check — I1/I5: vault-write path unchanged (same write_wiki_page; write_page
posture still env-only per ADR-0029 §2.3, untouched here). I3: UI is a token field + a switch +
one PUT, local state, no store churn. I6: mount path, private CIDRs, and IP ranges are named
constants; no provider, host, or token hardcoded. I7: no new loop. I8: migration ⇒ D2/ER
regen + D4/D5 refresh + ADR amendments. I9: reuses vault_state + the existing ADR-0029 mount +
existing FastMCP server; adds NO MCP capability, NO second writer, NO second process, NO broadened
tool set. I2/I4 untouched. The ADR-0008 tension is resolved by categorising the credential
(app-issued access token ≠ third-party provider key) and by hashing — not by trading §12 away.
7. Decisions the owner must make before coding¶
- O1 — Token storage: salted HASH (recommended, built here) vs re-displayable PLAINTEXT. This ADR builds the hash (DB-leak-safe; token shown once, then rotate-only). If the owner strongly prefers being able to re-view the token in the UI later (plaintext in DB), that re-introduces a recoverable secret in Postgres (against §12's spirit) and is not recommended — but it is the owner's call. Default: hash.
- O2 — Should token-less PUBLIC be HARD-BLOCKED (chosen here) or ALLOWED-WITH-WARNING? This ADR
hard-blocks token-less public access by design (public always requires the token). The
alternative — allow public-open with a scary UI warning — is explicitly not recommended
(a vault-mutating-capable open endpoint on the public internet). Confirm the hard-block. (Also:
do we additionally want per-route auth on
PUT /mcp/authitself, beyond the same-origin posture ADR-0032 settled? Default = no, consistent with ADR-0032 §2.4.) - O3 — Authenticated external REST API (out of scope, noted). This ADR gates only
/mcp/server; the rest of the REST API stays unauthenticated (else the browser UI breaks). If the owner ever wants an authenticated external REST path, that is a separate ADR. Default: MCP-surface-only.