ADR-0032 — Remote MCP runtime toggle: persisted remote_mcp_enabled flag, always-mount + 404-gate, no remount¶
- Status: Accepted (owner decided 2026-06-29: "real toggle" — a persisted runtime flag, not an env-only switch; bearer token remains the mandatory security floor;
write_pageremote control stays env-only / out of scope) - Superseded in part by ADR-0033 (2026-06-30): §2.4's token-floor clamp is now allow-aware — remote can be enabled when EITHER a token is configured OR
mcp_allow_without_token=true(private sources only). The clamp still applies for public (Cloudflare tunnel) requests, which always require a token. - Date: 2026-06-29
- Sprint: v0.5 (Feature — make the API + MCP settings tab "app-like": enable/disable the remote MCP endpoint and show its connection URL)
- Feature: F1-MCP-UI (Amendment — owner request, Emanuele Chiummo) · builds on ADR-0029 (remote MCP over HTTP) and ADR-0027 (
/mcp/infointrospection + read-only API+MCP panel) - Invariants owned: I9 (reuse the existing config store + existing FastMCP mount; add NO new MCP capability, NO second writer) · I6 (nothing hardcoded —
mount_pathcomes from the server; no provider touched) · I1/I5 (no vault-write-path change —write_pagestill routes throughwrite_wiki_pagewhen enabled) · I3 (UI: a toggle + a fetch/PUT — no heavy render, no store churn) · I7 (no new loop) - Author: solution-architect
- Implementers: backend-engineer (column + migration + flag cache + middleware gate +
PUT /mcp/remote+/mcp/infofields) · frontend-engineer (toggle control + URL display +setRemoteMcpEnabledclient + i18n) · tech-writer (D2 ER regen note, D4 OpenAPI, D6b deploy note) · devops-engineer (migration is run by the standardalembic upgrade head— a one-line note only, see §3)
1. Context¶
ADR-0029 shipped the remote MCP surface: build_http_mcp() (backend/app/mcp/server.py)
produces a FastMCP Streamable-HTTP ASGI app, mounted at /mcp/server in
backend/app/main.py only when MCP_AUTH_TOKEN is set (fail-closed), wrapped by
_BearerAuthMiddleware (constant-time hmac.compare_digest). settings.mcp_http_enabled
is derived bool(mcp_auth_token). MCP_REMOTE_WRITE_ENABLED (default false) controls whether
write_page is on the HTTP surface.
ADR-0027 made the API + MCP settings panel (SectionApiMcp in
frontend/src/components/settings/SettingsPanel.tsx) display-only (I9): fetch-on-mount,
shows transport + entry command + Claude Desktop snippet + tools list. No config-write.
Owner request (locked): the API + MCP tab must let the owner enable/disable the remote MCP endpoint and see the connection URL when active — "app-like". The owner explicitly chose the real toggle option (a persisted runtime flag), not an env-only restart switch.
Two facts the design must honour:
-
The bearer token is the security floor. A vault-mutating-capable public endpoint must never be reachable without auth. So: no token configured ⇒ remote stays OFF regardless of the toggle, and the UI must tell the user to set a token first. The runtime flag can only ever narrow exposure below what the token would allow — it can never open exposure without a token.
-
write_pageremote control is out of scope. The owner did NOT ask for UI write-control.MCP_REMOTE_WRITE_ENABLEDstays env-controlled exactly as today (ADR-0029 §2.3). This ADR touches only the reachability of the surface, not which tools it carries.
State at write time (verified in code): McpInfoResponse (main.py ~1719) already carries
http_enabled and remote_write_enabled (ADR-0029 §2.5 was implemented). The frontend
McpInfoResponse interface (frontend/src/api/providerClient.ts:123) does not yet carry
those, nor the new fields this ADR adds. The mount is built once at module load and mounted once
(main.py:113, :298); there is no per-request gate today.
2. Decision¶
2.1 Storage — reuse vault_state; add one nullable boolean column (I9)¶
There is no app-global KV / app-settings table today. The candidate stores are
provider_config (per-scope F17 rows — wrong shape; it is a multi-row routing table, not a
singleton posture) and vault_state (one row per VAULT_ID, seeded idempotently at startup,
already read on the request path for data_version).
Decision: reuse vault_state. Add one nullable boolean column:
vault_state.remote_mcp_enabled : Mapped[bool] (Boolean, nullable=False,
default=False, server_default=sa_text("false"))
- Why
vault_stateand not a new table: the remote MCP surface is a single origin-level posture for this single-process, single-VAULT_IDdeployment.vault_statealready holds exactly one row for the active vault, is seeded idempotently (_seed_vault_state,main.py:3061), and is already loaded at startup. A boolean posture column rides that row with zero new table, zero new service. (I9 — do not add infrastructure we do not need.) - Why not
provider_config: that table is keyed by(scope, operation, vault_id)for F17 routing resolution. A boolean posture has no scope semantics; forcing it in would pollute the routing resolver. Rejected. - Default = OFF (
false). A fresh DB, an upgrade of an existing DB (the column backfillsfalse), and the seed path all yield remote-OFF. The owner must explicitly turn it on. - Naming clarity: the column is named
remote_mcp_enabledto read unambiguously as "the remote (HTTP) MCP surface is enabled at runtime", distinct frommcp_http_enabled(the derived "a token is configured" capability gate).
This DOES add an Alembic migration — a new revision 0011_vault_state_remote_mcp_enabled
that ALTER TABLE vault_state ADD COLUMN remote_mcp_enabled BOOLEAN NOT NULL DEFAULT false.
D2 / ER MUST regenerate (make er) and match the live schema (I8). This is the one and only
schema change in this ADR. (Flagged for tech-writer + the docs gate.)
2.2 The flag is read through an in-process cache, DB-backed (hot-path safety)¶
The middleware gate (§2.3) runs on every /mcp/server request. Reading the DB per MCP
request would add a query to a hot path and couple request latency to the DB. Instead:
- A module-level
RemoteMcpFlagholder (a tiny object with anasyncio.Lock+ a cachedbool) is the single read source for the middleware. It is not a new persistence layer — the DB column is the source of truth; the holder is a process cache of it. - Load at startup: in
lifespan, after_seed_vault_state(), readvault_state.remote_mcp_enabledfor the activeVAULT_IDinto the holder (one query, once). - Refresh on write: the
PUT /mcp/remotehandler (§2.3) writes the column and updates the holder in the same call, so the gate reflects the new value immediately with no DB read on the request path. (Single-process deployment ⇒ in-memory cache and DB never diverge.) - The middleware reads
RemoteMcpFlag.is_enabled()— an in-memory bool — synchronously fast.
This keeps the gate O(1) in-memory while persistence survives restart.
2.3 Gating — ALWAYS mount when a token is set; the middleware 404-gates on the runtime flag (NO remount)¶
The mount/unmount approach is rejected. FastMCP's http_app() carries its own Starlette
lifespan that starts/stops the StreamableHTTP session manager; that lifespan is entered once
in the FastAPI lifespan (main.py:229). Mounting/unmounting the sub-app at runtime would mean
starting/stopping that session manager mid-process — fragile, and Starlette has no first-class
runtime-unmount. We do not go there.
Decision — always-mount + 404-gate (no remount):
- The mount condition is unchanged from ADR-0029: when
MCP_AUTH_TOKENis set, the sub-app is built once and mounted once at/mcp/server; its lifespan/session-manager is started once. When the token is unset, nothing is mounted (fail-closed, exactly as today). _BearerAuthMiddlewaregains a flag check that runs before the bearer check, on HTTP scopes only:- Runtime flag OFF ⇒ return
404 Not Found— byte-identical to the path not being mounted. This avoids an information leak: a probe cannot distinguish "remote disabled" from "no MCP here at all", and in particular a 404 (vs 401) does not advertise that a protected MCP surface exists behind the toggle. - Runtime flag ON ⇒ enforce the bearer token exactly as today (constant-time compare; missing/wrong ⇒ 401).
- Lifespan / WebSocket scopes are passed through unconditionally (unchanged) so the session manager always starts/stops correctly. The session manager runs even when the flag is OFF — that is intentional and correct: the gate blocks request routing (returns 404 before the request reaches the sub-app), it does not stop the session manager. There is no session leak because no MCP session is ever established while the gate 404s (the request never reaches the protocol layer).
This means the StreamableHTTP session manager is "mounted + lifespan-started once" and the runtime toggle is a pure request-routing decision in front of it. No remount, no session-manager restart, no lifespan churn.
2.4 Config-write endpoint — PUT /mcp/remote (the toggle), token-floor clamp¶
Add one REST route to backend/app/main.py:
PUT /mcp/remote
request body: { "enabled": bool }
response body: McpRemoteStateResponse {
remote_enabled: bool, # the resulting persisted runtime flag (post-clamp)
token_configured: bool, # whether MCP_AUTH_TOKEN is set (the floor)
mount_path: str, # "/mcp/server" — so the UI can rebuild the URL
clamped: bool # true iff the request asked enabled=true but no token ⇒ forced off
}
Token-floor clamp (mandatory):
- If settings.mcp_auth_token is unset and the body is enabled=true, the handler
refuses to enable: it persists remote_mcp_enabled = false, sets clamped = true, and
returns remote_enabled=false, token_configured=false. Remote can never be enabled without
the auth floor. (HTTP status: 200 with clamped=true — the request is well-formed and the
server's posture is reported truthfully; the UI renders a "set a token first" hint from
token_configured=false. Returning 200-with-clamp rather than 4xx keeps the toggle's response
shape uniform for the UI, which always reads back the authoritative posture.)
- If a token IS set: persist the requested value, refresh the in-process holder (§2.2), return it.
- enabled=false always succeeds (turning off needs no token).
Side effect on token state: if the token is later unset (env change + restart), the persisted
remote_mcp_enabled may still be true in the DB, but with no token the sub-app is not mounted
at all (§2.3, ADR-0029) ⇒ /mcp/server 404s regardless. The stored true is dormant and
re-applies only if a token is restored. This is correct fail-closed behaviour; /mcp/info
(§2.5) reports remote_enabled AND token_configured so the UI never shows a misleading "ON".
Auth on the toggle endpoint itself. The rest of the same-origin REST API is unauthenticated (ADR-0028 — relative base, server-only proxy target; the threat model is a single-user self-hosted app behind Tailscale mesh + Cloudflare Tunnel, CLAUDE.md §1). This endpoint toggles public exposure, so it warrants thought:
- Decision: leave it same-origin / unauthenticated, consistent with the rest of the REST API.
Rationale: (a) reaching the REST API at all already requires being on the Tailscale mesh or
past the Cloudflare Tunnel — the network perimeter is the gate for every REST route, and this
route is no more sensitive than, e.g.,
POST /ingestwhich mutates the vault; (b) the bearer token remains the floor for the MCP surface itself — flipping the flag ON without a token is clamped to OFF (above), so the worst an unauthenticated toggle can do is expose a bearer-protected surface, not an open one; (c) adding bespoke auth to one REST route would diverge from the uniform same-origin posture (ADR-0028) for marginal gain in this threat model. - Stated trade-off: if the owner later puts the web UI behind Cloudflare Access (edge auth for the whole origin), the toggle inherits that protection for free — the preferred hardening path, and a pure-ops change (no code). We explicitly do not invent app-level auth for a single REST route here. If multi-user is ever on the table, this route (and the rest of the API) gets real auth together — out of scope now.
2.5 /mcp/info additions (UI reads posture + builds the URL)¶
McpInfoResponse (backend main.py, frontend providerClient.ts) gains three read-only
fields (token is NEVER returned):
| Field | Type | Source | Meaning |
|---|---|---|---|
token_configured |
bool | bool(settings.mcp_auth_token) |
True iff a bearer token is set (the floor). Never the token. |
remote_enabled |
bool | RemoteMcpFlag.is_enabled() (the persisted runtime flag) |
The runtime toggle state. |
mount_path |
str | the mount constant "/mcp/server" (a module constant, not a JSX/handler literal — I6) |
So the UI builds the URL as window.location.origin + mount_path. |
http_enabled(already present) stays =bool(mcp_auth_token)=token_configured. To avoid two names for one fact,http_enabledis retained for backward compat andtoken_configuredis the named-for-UI alias; both reflect the same boolean. (The UI usestoken_configured+remote_enabledfor its three-state display;http_enabledstays so no existing consumer breaks.)remote_write_enabled(already present) unchanged — env-driven, out of scope here.- The URL is composed client-side from
origin + mount_path; the server never emits a host (it does not know the public tunnel host). No hostname is hardcoded anywhere (I6).
Frontend type parity: frontend/src/api/providerClient.ts McpInfoResponse must add
http_enabled, remote_write_enabled (currently missing — they exist on the wire but not the
type), plus the new token_configured, remote_enabled, mount_path. Add a typed
setRemoteMcpEnabled(enabled: boolean): Promise<McpRemoteStateResponse> client for PUT /mcp/remote.
2.6 Reconcile with ADR-0027 I9 ("display-only" panel) — scoped, documented exception¶
ADR-0027 §2.4 and its Do-NOT list (§4 items 3–4) made the API + MCP panel display-only — no tool invocation, no config-write. This control makes the panel write one piece of config (the runtime toggle). That is a deliberate, narrowly-scoped exception, and this ADR documents it explicitly:
- ADR-0032 supersedes ADR-0027's "no config-write" clause for THIS SINGLE control only. Everything else in the panel stays exactly as ADR-0027 specified: transport, entry command, Claude Desktop snippet, and tools list remain read-only display.
- The I9 headline is preserved. ADR-0027's I9 concern was "do not reinvent MCP / do not add a
second MCP server / do not invoke MCP tools / do not duplicate
server.pylogic." None of that changes: the toggle invokes no MCP tool, adds no MCP capability, and duplicates no tool logic. It flips a posture flag that gates the existing ADR-0029 mount. The only ADR-0027 guardrail relaxed is "no config-write," and only forremote_mcp_enabled. - The panel still cannot mutate the vault or call a tool — it has exactly one new write path
(
PUT /mcp/remote) and no others. ADR-0027 §4 items 1, 2, 3, 5, 6 (no hardcoded tools/server, no tool invocation, no second server) all still hold.
This interplay is the only invariant tension in the ADR and it is resolved by scoping, not by trading I9 away.
2.7 Frontend control (I3 — no heavy render)¶
SectionApiMcp gains a small Connection / Remote sub-block above the existing read-only rows:
- A toggle (switch) bound to
info.remote_enabled. - Three display states, driven by
token_configured+remote_enabled: token_configured === false→ toggle disabled, with an inline hint (i18nsettings.apiMcp.remote.needToken): "SetMCP_AUTH_TOKENto enable the remote endpoint." No URL shown.token_configured === true && remote_enabled === false→ toggle off, enabled; no URL.token_configured === true && remote_enabled === true→ toggle on; show the connection URL as a mono rowwindow.location.origin + info.mount_pathwith a copy button (mirrors the existing snippet copy control). No token displayed.- Flipping the toggle calls
setRemoteMcpEnabled(next), then re-reads/mcp/info(or applies the returnedMcpRemoteStateResponse) to refresh the three-state display. Local component state + fetch/PUT only — no new Zustand store, no store churn (I3, mirrors ADR-0027 §2.4). The render cost is a switch + one mono row; no heavy work. - i18n: new keys under
settings.apiMcp.remote.*inen.json+it.json(key-set parity gate). The URL and the (absent) token are never i18n keys — only surrounding labels are.
3. New config / schema / migration¶
| Kind | Name | Type / default | Where | Notes |
|---|---|---|---|---|
| DB column | vault_state.remote_mcp_enabled |
BOOLEAN NOT NULL DEFAULT false |
models.py VaultState |
NEW — adds Alembic migration; D2/ER MUST regenerate. Default OFF. |
| migration | 0011_vault_state_remote_mcp_enabled |
add column | backend/alembic/versions/ |
ALTER TABLE vault_state ADD COLUMN remote_mcp_enabled BOOLEAN NOT NULL DEFAULT false. Run by standard alembic upgrade head (devops: one-line note, no infra change). |
| process cache | RemoteMcpFlag |
in-memory bool + lock | main.py (or a tiny module) |
Loaded from DB at startup; refreshed on PUT /mcp/remote. Not a new persistence layer. |
| route | PUT /mcp/remote |
body {enabled: bool} → McpRemoteStateResponse |
main.py |
Token-floor clamp; same-origin/unauthenticated (§2.4). |
| response fields | McpInfoResponse.{token_configured, remote_enabled, mount_path} |
bool, bool, str | main.py + providerClient.ts |
Token NEVER returned (§2.5). |
| no env change | — | — | — | MCP_AUTH_TOKEN, MCP_REMOTE_WRITE_ENABLED unchanged (ADR-0029). |
Migration IS introduced. This is the single schema change; flagged explicitly so the docs
gate regenerates docs/er/schema.mmd (make er) and confirms it matches the live schema (I8).
4. Per-agent file ownership¶
backend-engineer:
- backend/app/models.py — add remote_mcp_enabled to VaultState.
- backend/alembic/versions/0011_vault_state_remote_mcp_enabled.py — new migration (add column, default false; downgrade drops it).
- backend/app/main.py —
- RemoteMcpFlag holder + startup load (in lifespan, after _seed_vault_state);
- extend _BearerAuthMiddleware with the flag-OFF ⇒ 404 gate (before the bearer check, HTTP scopes only; lifespan/WS pass-through unchanged);
- PUT /mcp/remote handler + McpRemoteStateResponse model (token-floor clamp + holder refresh);
- add token_configured, remote_enabled, mount_path to McpInfoResponse + get_mcp_info;
- define MCP_MOUNT_PATH = "/mcp/server" as a single module constant used by the mount, the gate, and /mcp/info (I6 — no duplicated literal).
- backend/tests/ — gate tests (flag OFF ⇒ 404; flag ON + bad token ⇒ 401; flag ON + good token ⇒ pass; clamp when no token; /mcp/info fields; no token leak).
frontend-engineer:
- frontend/src/api/providerClient.ts — extend McpInfoResponse (add http_enabled, remote_write_enabled, token_configured, remote_enabled, mount_path); add McpRemoteStateResponse type + setRemoteMcpEnabled().
- frontend/src/components/settings/SettingsPanel.tsx — SectionApiMcp Remote sub-block (toggle + three-state display + URL row + copy), local state only (I3).
- frontend/src/i18n/en.json, it.json — settings.apiMcp.remote.* keys (parity).
- frontend/src/tests/ — vitest: toggle disabled when token_configured=false; URL shown only when remote_enabled=true; setRemoteMcpEnabled PUT shape; no token rendered.
tech-writer:
- docs/er/schema.mmd — regenerate via make er (D2; reflects the new column).
- docs/api/openapi.json — regenerate via make openapi (D4; PUT /mcp/remote + new /mcp/info fields).
- docs/DEPLOY.md (D6b) — note: the remote MCP is now toggled in the UI (persisted), gated by MCP_AUTH_TOKEN as the floor.
- docs/screens/settings-api-mcp.png — refresh via Playwright (D5) to show the toggle.
devops-engineer: one-line note only — the new column is applied by the standard
alembic upgrade head already in the deploy/compose flow; no new service, port, or env.
5. Acceptance checks (DoD)¶
- Default OFF. Fresh DB and upgraded DB both yield
vault_state.remote_mcp_enabled = false;GET /mcp/inforeturnsremote_enabled=false. - Gate OFF ⇒ 404. With
MCP_AUTH_TOKENset andremote_mcp_enabled=false, a request to/mcp/server(with OR without a valid bearer) returns 404, indistinguishable from not-mounted; no MCP session is established. - Gate ON ⇒ bearer enforced. With token set and
remote_mcp_enabled=true: no/bad bearer ⇒ 401; correct bearer ⇒ tools list (3, or 4 ifMCP_REMOTE_WRITE_ENABLED=true) — exactly ADR-0029 behaviour. - No remount / session manager stable. Flipping the toggle ON→OFF→ON at runtime never restarts the StreamableHTTP session manager (mounted + lifespan-started once); verified by log assertion that the session manager start/stop happens only at process start/stop.
- Token-floor clamp. With
MCP_AUTH_TOKENunset,PUT /mcp/remote {enabled:true}returnsremote_enabled=false, token_configured=false, clamped=true, and/mcp/server404s (not mounted at all). The DB does not persisttruein this case. /mcp/infoposture + no leak.GET /mcp/inforeturnstoken_configured,remote_enabled,mount_path="/mcp/server", and never the token value.grepproves no token in any response.- Persistence across restart. Set ON via UI, restart the process:
remote_enabledreloads astruefromvault_state; the gate honours it. - UI three-state. vitest:
token_configured=false⇒ toggle disabled + "set a token" hint, no URL;remote_enabled=true⇒ URL =origin + mount_pathshown with copy; no token ever rendered. - I1/I5 unchanged.
grepproves the HTTPwrite_page(when exposed) still routes throughwrite_wiki_page; no new write path, no second writer. - Docs gate.
make erregenerated and matches live schema;make openapiincludesPUT /mcp/remote+ new fields; D5 screenshot refreshed (I8).
6. Consequences¶
Positive — the owner gets a real, persisted, app-like toggle with the connection URL on
display. The bearer token stays the hard security floor (toggle can only narrow, never open).
No remount fragility: the session manager is mounted+started once; the toggle is a pure
request-routing 404-gate. Reuses vault_state — no new table, no new service (I9). The URL is
built client-side from origin + mount_path, nothing hardcoded (I6). write_page posture is
untouched (env-only), so the vault-write surface is unchanged (I1/I5).
Trade-offs (explicit) —
- One migration is introduced (the only schema change); D2/ER must regenerate (handled in the
docs gate). Acceptable: a single nullable-default boolean column on an existing one-row table.
- The toggle endpoint is same-origin / unauthenticated, consistent with the rest of the REST
API (ADR-0028) and the Tailscale+Cloudflare threat model. The mitigation is structural: the
worst an unauthenticated flip can do is expose a bearer-protected surface (the clamp forbids a
token-less ON). Edge auth (Cloudflare Access over the origin) is the recommended hardening path
and is pure-ops. If multi-user ever lands, the whole REST API gets real auth together.
- A dormant true can persist in the DB while the token is unset; the surface still 404s
(not mounted), and /mcp/info reports both flags so the UI never shows a misleading "ON".
- A scoped exception to ADR-0027's "display-only" panel is introduced — one config-write
control. Documented in §2.6; ADR-0032 supersedes that single clause only; all other read-only
guarantees and the I9 headline (no second server, no tool invocation, no logic duplication)
hold.
Invariant check — I1/I5: vault-write path unchanged (same write_wiki_page; write_page
posture env-only). I3: UI is a switch + one fetch/PUT, local state, no store churn, no heavy
render. I6: mount_path and the URL are derived (server constant + client origin); no host or
provider hardcoded. I7: no new loop. I8: migration ⇒ D2/ER regen + D4/D5 refresh in the
docs gate. I9: reuses vault_state + the existing ADR-0029 mount + existing FastMCP server;
adds no MCP capability, no second writer, no new infra. I2/I4 untouched. The only invariant
tension (ADR-0027 I9 display-only) is resolved by scoping, not by trading the invariant away.
7. Decisions locked (no open owner questions)¶
The owner has already decided the "real toggle" path and the token-floor / write-out-of-scope constraints. This ADR is implementable as written. The single forward-looking choice — edge auth on the toggle endpoint — is deferred as a pure-ops hardening option (§2.4), not a code decision.