Remote team server
Share one palace with a team over MCP HTTP:
cp deploy/server.env.example deploy/.env # set UNDERCROFT_MCP_HTTP_TOKEN
docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
Clients:
claude mcp add --transport http undercroft http://HOST:8765/mcp \
--header "Authorization: Bearer $UNDERCROFT_MCP_HTTP_TOKEN"
- The server refuses non-loopback binds without the token.
--read-onlyexposes recall without write access (see the compose file)./healthzis unauthenticated for probes.- Plain HTTP: terminate TLS in a reverse proxy for anything beyond a trusted network.
- Backing store: the palace volume is the system of record; Qdrant only ever receives sealed content + embeddings.
Systemd alternative: deploy/undercroft-server.service.
Multi-tenant REST surface (/v1)
serve-http also exposes a versioned REST API in the same process, behind
the same bearer, for programmatic (non-MCP) callers and for orchestration
platforms that use one vault per tenant. One palace per process stays
the model — tenancy is vaults, not palaces.
All 35 routes, counted against route() in
crates/undercroft-cli/src/tenant.rs rather than remembered — this table
listed 18 of them until 2026-08-05, omitting the whole operator plane
(trust, admission review, retention, forgetting) plus the golden-values
tier. Everything under operator plane is deliberately absent from MCP:
an agent must not rule on the queue that exists to contain it, nor assign
the trust class that decides what it may retrieve.
── lifecycle ────────────────────────────────────────────────────────────
POST /v1/vaults {id, level?, embedder?} create vault
GET /v1/vaults list vault ids
DELETE /v1/vaults/{id} delete vault
── read / write ─────────────────────────────────────────────────────────
GET /v1/vaults/{id}/stats (records, level, writes, chain head,
wings, rooms, kg, tunnels, db_bytes,
codebooks)
GET /v1/vaults/{id}/stats/history ?window=N sample ring buffer
(501 without --features telemetry)
POST /v1/vaults/{id}/drawers {text, wing?, room?, vector?, dedup_threshold?}
202 + {quarantined:true} if diverted
GET /v1/vaults/{id}/drawers ?wing=&room=&limit=&offset= paged summaries
GET /v1/vaults/{id}/drawers/{drawer_id} one full drawer
PUT /v1/vaults/{id}/drawers/{drawer_id} {text} replace content
DELETE /v1/vaults/{id}/drawers/{drawer_id}
POST /v1/vaults/{id}/search {query, wing?, room?, limit?, vector?, …}
GET /v1/vaults/{id}/taxonomy (wing → room tree with counts)
── knowledge graph (read-only browse, plus the authority tier) ───────────
GET /v1/vaults/{id}/kg/stats (entity/triple/active/closed counts)
GET /v1/vaults/{id}/kg/entities ?limit=&offset= paged entities
GET /v1/vaults/{id}/kg/query ?entity=&direction=&as_of= facts about one entity
GET /v1/vaults/{id}/kg/timeline ?entity= temporal fact timeline
GET /v1/vaults/{id}/kg/receipts receipt verdicts per fact
(verified|source_changed|dangling|tampered)
GET /v1/vaults/{id}/kg/canonical/{key} the one active approved fact
POST /v1/vaults/{id}/kg/authority declare authority_class / review_state
GET /v1/vaults/{id}/supersessions drawer supersession links + verdicts
── operator plane (never on MCP) ────────────────────────────────────────
GET /v1/vaults/{id}/history audit chain (subject?, limit?, offset?)
GET /v1/vaults/{id}/trust wing trust assignments
POST /v1/vaults/{id}/trust assign one (closed vocabulary)
GET /v1/vaults/{id}/admission the pending review queue
POST /v1/vaults/{id}/admission rule allow | deny (deny is receipted)
GET /v1/vaults/{id}/retention policies per wing/room
POST /v1/vaults/{id}/retention set one
POST /v1/vaults/{id}/retention/sweep enforce; returns a proof receipt
POST /v1/vaults/{id}/forget provable destruction + attestation
── maintenance / portability ────────────────────────────────────────────
POST /v1/vaults/{id}/refine LLM distillation → KG
POST /v1/vaults/{id}/verify (HMAC + audit-chain report)
POST /v1/vaults/{id}/anchor (tighten the manifest rollback anchor; a write)
POST /v1/vaults/{id}/rotate (re-key the vault; sole-writer contract)
GET /v1/vaults/{id}/export (decrypted NDJSON: {drawer, vector} per line)
POST /v1/vaults/{id}/import (NDJSON body; returns {imported, quarantined})
── not under /v1 ────────────────────────────────────────────────────────
GET /ui (vault admin console; unauthenticated static page)
GET /healthz (unauthenticated)
The console at /ui is a /v1 CLIENT, not a fourth surface. It has no
capability of its own and no code path the REST API does not expose, so the
drift rule (CLI / MCP / /v1 / orchestrator) does not add a column for it —
but a fix that lands on /v1 and not on the page is still a defect the user
meets, which is how a success toast came to be shown for a 202 {"quarantined": true}. Stated because several boundaries in these documents
rest on it and none of them said so (ROADMAP C14).
The admin console at /ui drives this whole surface from a browser:
vault lifecycle, stats, verification, key rotation, drawer browsing with
verbatim view/edit/delete, search, and export/import. The page itself
carries no secrets — the bearer (and the assertion secret, under per-vault
isolation) are entered in the page and never leave the tab; assertions are
minted in-browser with WebCrypto. Destructive operations require typing the
target’s name.
Vault lifecycle over HTTP lets an orchestrator auto-provision a dedicated
memory instance per tenant and migrate a vault between instances:
export → verified import → drop. Import returns the exact record count so
the caller can verify before dropping the source.
level is sealed (default) or hmac-only. embedder is hash
(default) or external:<name>@<dim> (see below).
--read-only, precisely. It is a posture on the whole process, not a
filter on one port, and the gate sits in front of dispatch rather than
at the top of each mutating handler — because the per-handler version had
thirteen guards for fourteen mutating routes and POST …/kg/authority
never got one. It fails closed: every GET is served, and every
non-GET is refused with 403 unless it is one of two named reads —
POST …/search and POST …/verify (both POST for cost, not for effect).
A route added later is refused until someone deliberately names it. This
paragraph used to say “only reads (stats, search, export) are served”,
which under-listed the reads and omitted verify entirely.
The open is covered too, since 1.0.0. This paragraph used to name it
as the thing --read-only did not cover — opening a store created schema,
initialised the chain, and ran a rotation reconcile that could promote or
delete a staged vault.json.next, all lazily on the first request against a
cold handle. The connection is now SQLITE_OPEN_READ_ONLY under PRAGMA query_only=ON; the schema is checked rather than created, a lagging manifest
anchor is reported rather than healed, and a staged rotation is honoured in
memory with its file untouched. Whatever the open declined to repair appears
as unhealed on GET /v1/vaults/{id}/stats beside read_only. Two
conditions refuse with 409 instead: a manifest whose palace.db is
absent, and a schema this build would have had to migrate.
What is still not a claim: a read-only connection materialises SQLite’s WAL
scaffolding (-shm, and a zero-length -wal) where the directory is
writable — no database content, and where the directory is not writable the
open escalates to immutable=1 and warns. If you need a genuinely
byte-frozen vault, stop the server rather than restarting it read-only.
Per-vault request authorization
The palace-wide bearer proves the caller reached the right server; it does
not distinguish tenants. Set UNDERCROFT_ASSERTION_SECRET and every /v1
request must additionally carry a short-lived assertion for the exact vault
it addresses — and so must POST /mcp, for the vault the server was started
with (--vault). Both transports are gated, or the one the MCP handler
serves would stay open to a bare bearer:
X-Vault-Assertion: <unix_ts>:<hex>
hex = HMAC-SHA256(secret, "<unix_ts>|<vault_id>")
The caller platform authorizes its user, then mints the assertion; the engine verifies it independently, so a compromised caller component that lacks the secret gets nothing. An assertion minted for vault A never authorizes vault B (the vault id is inside the MAC), a timestamp outside ±120s is refused, and comparison is constant-time. Any failure is a bare 401 — the reason is logged server-side, never returned.
Mint one for testing or from a shell with undercroft assert-header <vault>
(reads UNDERCROFT_ASSERTION_SECRET); production callers reimplement the
same one-line HMAC in their own stack.
export UNDERCROFT_ASSERTION_SECRET=…
H=$(undercroft assert-header acme)
curl -s http://HOST:8765/v1/vaults/acme/search \
-H "Authorization: Bearer $UNDERCROFT_MCP_HTTP_TOKEN" \
-H "X-Vault-Assertion: $H" \
-d '{"query":"which database for billing"}'
Externally-supplied embeddings
A vault created with embedder: "external:<name>@<dim>" stores
caller-provided vectors and never runs a local model — for platforms that
already own an embedding space (embedding through their own model gateway
for spend attribution, shared across ingest, sync, and migration). Such a
vault requires a vector of exactly <dim> floats on every drawer write
and on every search, refuses writes without one, and enforces the recorded
dimension exactly like any other embedder identity. Sealed vaults seal
these vectors the same way as internally-computed ones.
Semantic dedup-refresh on save
Pass dedup_threshold on a drawer write to collapse near-duplicates: if an
existing drawer in the same wing+room has embedding cosine >= threshold,
it is refreshed in place (text/metadata/recency updated, id kept) and the
response reports {"deduped": true, "id": …}. This makes bulk
re-ingestion of an updated corpus idempotent — re-running an importer
refreshes unchanged facts instead of piling up near-copies. A refresh is an
ordinary audited update (re-tagged, chain advanced), never a silent
overwrite.
Orchestrated deployment (one instance per tenant)
The master key is injected at start; init runs headless with no prompts
and never logs key material. A container orchestrator can stamp out one
Undercroft per tenant:
services:
undercroft:
image: undercroft:latest
command: ["serve-http", "--host", "0.0.0.0", "--port", "8765"]
environment:
# Master key material — inject from your secret store, never bake in.
UNDERCROFT_PASSPHRASE: ${TENANT_PASSPHRASE}
UNDERCROFT_MCP_HTTP_TOKEN: ${PALACE_BEARER}
UNDERCROFT_ASSERTION_SECRET: ${ASSERTION_SECRET}
volumes:
- tenant-data:/data # palace: vaults, keys, audit chain
# Front with a TLS-terminating reverse proxy; /healthz for probes.
volumes:
tenant-data:
Bootstrap is non-interactive: with UNDERCROFT_PASSPHRASE set, undercroft init (or the first serve-http, which opens the default vault) derives the
master key via Argon2id and writes it under /data with 0600 permissions
— no TTY, no prompt, and the key is never emitted to logs. Provision each
tenant’s vaults over /v1/vaults once the instance is up.