Found by running the service for real: creating a file in the vault while mdmem is up only logged 'reindex of changed file failed' until the next scan, which is exactly the workflow the vault is meant to support. Adds the live test suite (-m live) and the llama-swap model entry.
mdmem
Markdown-canonical agent memory service. One self-hosted process that gives every harness (Claude Code, claude.ai, Open WebUI, Hermes, opencode…) a single shared long-term memory over MCP, with markdown files as the only source of truth.
Full design and its evidence: PLAN.md. Section numbers below refer to it.
- Markdown is canonical.
/data/vault/{inbox,active,archive}/*.md. The SQLite index is derived and rebuildable; delete it and the service rebuilds from the files. - Hybrid retrieval. FTS5 (BM25,
unicode61 remove_diacritics 2, optional trigram)- sqlite-vec KNN, fused with RRF (default) or convex combination. No graph traversal in the retrieval path (§2.7).
- Agents propose, the owner disposes. Agent writes land in
inbox/; an agent editing an approved note creates a proposed revision, never an in-place change. There is no delete tool (§2.4). - Memory content is untrusted data. Tool descriptions and the MCP
instructionssay so; the UI renders markdown with raw HTML disabled.
Layout
src/mdmem/
config.py settings from the environment (§12)
notes.py frontmatter round-trip (ruamel): unknown keys, order and comments survive
vaultio.py atomic write → fsync → replace, pre-write history snapshot
chunker.py atomic notes are one chunk; long ones split on H2/H3 (§6)
db.py schema, sqlite-vec, single-writer rule (§5)
index.py vault → rows, links, duplicate hints
embed.py Bifrost client, content-hash cache, FakeEmbedder for tests (§7)
search.py the retrieval algorithm (§8)
service.py the one code path every write goes through — MCP and UI alike
mcp_server.py six memory_* tools, bearer auth, idempotency, audit log (§9)
ui/ FastAPI + Jinja + HTMX, PocketID OIDC with a group check (§10)
evalharness.py metrics, bootstrap CIs, ablation matrix (§11)
deploy/ Kubernetes manifests, laid out for the fluxcd repo (§13a)
eval/ query-set template, synthetic corpus, results
Local development
uv sync --extra dev
uv run pytest # 190+ tests, no network, no GPU
uv run pytest -m live # only the tests that hit the real Bifrost endpoint
uv run ruff check src tests && uv run ruff format --check src tests
Run it against a scratch vault with no embedding backend and no login:
VAULT_DIR=/tmp/mdmem/vault INDEX_DB=/tmp/mdmem/index.db CACHE_DB=/tmp/mdmem/cache.db \
HISTORY_DIR=/tmp/mdmem/.history MCP_TOKEN=dev SESSION_SECRET=$(openssl rand -hex 32) \
UI_AUTH_DISABLED=1 LOG_STYLE=text uv run mdmem serve
UI_AUTH_DISABLED=1 is for a port-forwarded local UI only. It logs an error on
startup, and an HTTPRoute must never point at a listener running with it (§14.19).
Other commands: mdmem reindex [--full] [--no-embed], mdmem doctor,
mdmem eval --queries eval/queries.yaml [--ablate].
Build and push
There is no CI runner in the cluster, so builds are manual from the workstation:
podman build -t harbor.cloud.traunseenet.com/library/mdmem:0.1.0 -f Containerfile .
podman push harbor.cloud.traunseenet.com/library/mdmem:0.1.0
podman inspect --format '{{index .RepoDigests 0}}' harbor.cloud.traunseenet.com/library/mdmem:0.1.0
Put tag and digest in deployment.yaml. Never :latest + imagePullPolicy: Always
for this service: without a runner, an unpinned tag means the running code is not in
git, and a pod restart becomes an unplanned upgrade of your memory store (§13a.2, §14.20).
Deploy (first install)
deploy/kubernetes/apps/apps/mdmem/** mirrors the fluxcd repo's nesting; copy it in.
The manifests were written from §13a and validated with kubectl kustomize, but not
against a live cluster — verify each step.
- Build and push the image; record the digest and put it in
deployment.yaml. - Seed Vault:
vault kv put secret/apps/mdmem \ MCP_TOKEN="$(openssl rand -hex 32)" \ BIFROST_VK="<vk from the Bifrost UI>" \ OIDC_CLIENT_SECRET="<from PocketID>" \ SESSION_SECRET="$(openssl rand -hex 32)" vault kv patch secret/apps/bifrost MDMEM_AUTH_HEADER="Bearer <the same MCP_TOKEN>" - In PocketID: create the
mdmem_adminsgroup, add your account, create the OIDC client with redirect URIhttps://memory.cloud.traunseenet.com/ui/auth/callback, and confirm the client emitsgroupsin the ID token — decode a real token, don't assume. Client id →mdmem-env, client secret → Vault. - Create the restic repo for
restic-mdmem; copysecret.example.yamltosecret.yaml, fill it in, SOPS-encrypt it. - Commit
kubernetes/apps/apps/mdmem/**. Check: ExternalSecretReady, PVCBound, Deployment healthy,/readyz200. - Verify embeddings end to end from inside the cluster:
A 404/400 means the alias or the provider prefix is wrong, not that mdmem is broken.
kubectl -n apps run curl --rm -it --restart=Never \ --image=harbor.cloud.traunseenet.com/docker.io/curlimages/curl -- \ sh -c 'curl -s http://bifrost.apps.svc.cluster.local:8080/v1/embeddings \ -H "Authorization: Bearer $VK" -H "content-type: application/json" \ -d "{\"model\":\"llamaswap/qwen3-embedding-4b\",\"input\":[\"test\"]}"' \ | jq '.data[0].embedding|length' # expect 2560 - Only then add
deploy/bifrost-mcp-client.jsonto Bifrost'sconfig.jsonand commit, so a failure is attributable to one change at a time. Create a VK scoped to the sixmemory_*tools. - Apply the NetworkPolicy and re-check the two things it can silently break: the pod
stays Ready (kubelet probes) and the Prometheus target stays
UP(§14.23). - Run a
ReplicationSourceand test a restore into a throwaway PVC before the vault holds anything you would miss.
Rotating MCP_TOKEN
The token lives in two Vault paths. Rotating one and not the other gives every agent a 401 that looks like an mdmem bug (§14.15):
NEW=$(openssl rand -hex 32)
vault kv patch secret/apps/mdmem MCP_TOKEN="$NEW"
vault kv patch secret/apps/bifrost MDMEM_AUTH_HEADER="Bearer $NEW"
# ESO refreshes both within 5 min; restart both pods to pick them up at once:
kubectl -n apps rollout restart deploy/mdmem deploy/bifrost
Connecting a harness
All harnesses go through Bifrost — mdmem's own port is never exposed.
- Claude Code
claude mcp add --transport http mdmem https://bifrost.cloud.traunseenet.com/mcp --header "Authorization: Bearer <vk>" - Open WebUI — External Tools → MCP (Streamable HTTP), same URL + VK header.
- claude.ai — custom connector against Bifrost's OAuth 2.1 endpoint.
client.allowed_originsin Bifrost'sconfig.jsoncurrently lists onlyhttps://bifrost.cloud.traunseenet.com; a browser-side connector flow may needhttps://claude.aiadded. - Hermes / opencode — remote MCP config, same URL + VK header.
Where the implementation deviates from PLAN.md
Each of these is a decision made while building, with the reason:
| § | Plan said | Built | Why |
|---|---|---|---|
| 3, 9 | mcp SDK, FastMCP |
mcp==2.2.0, MCPServer |
FastMCP was renamed in SDK 2.x; 1.2.0 has no Streamable HTTP at all. Same API shape, current SDK. |
| 5 | chunks_fts/chunks_tri are content='' |
ordinary FTS5 tables | Contentless FTS5 cannot delete a row before SQLite 3.43 (bookworm ships 3.40) and cannot snippet(). The index is rewritten on every edit, so deletes are the hot path. Costs a few MB of duplicated text. |
| 6 | "one asyncio task owns all SQLite writes (queue)" | one write connection behind a lock; async paths use asyncio.to_thread |
Same invariant (exactly one writer), and it also holds for the CLI and the tests, which have no event loop of the service's. |
| 8 | vec0 cosine | vec0 L2 over L2-normalised vectors, cos = 1 − d²/2 |
Monotone-equivalent and does not depend on a distance_metric= option in a pre-v1 extension. |
| 9 | — | MCP_ALLOWED_HOSTS (new setting) |
The SDK's DNS-rebinding protection checks the Host header. Without the in-cluster service name on the allow-list, every Bifrost call returns 421. Added to §14 as pitfall 24. |
| 10 | — | UI_AUTH_DISABLED (new setting) |
Makes §10's "P4 may ship without OIDC, but then without an HTTPRoute" explicit and loud instead of implicit. |
| 11 | eval/queries.yaml with 50–100 real queries |
eval/queries.example.yaml + a synthetic corpus and --fake-embeddings |
Real queries are Markus's to write, and the real model is not reachable from the build environment. The harness is complete and tested; the committed run is a machinery check, and the defaults stay as §2.5 specifies. |
Status
P0–P5 are implemented and tested. P6 is written but unverified: no cluster, registry, Vault or PocketID was reachable from the build environment, so the image was never built, the manifests were never applied, and the live checks in the deploy list above are still to do.