- Remove references to PLAN.md, SECURITY_ANALYSIS.md, deploy/ paths - Delete test_deploy.py (tests deleted K8s manifests) - Clean up eval/ references in comments and docstrings
9.7 KiB
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.
- 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.
- 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. - 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
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
db.py schema, sqlite-vec, single-writer rule
index.py vault → rows, links, duplicate hints
embed.py Bifrost client, content-hash cache, FakeEmbedder for tests
search.py the retrieval algorithm
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
ui/ FastAPI + Jinja + HTMX, PocketID OIDC with a group check
evalharness.py metrics, bootstrap CIs, ablation matrix
tests/ pytest suite
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.
Other commands: mdmem reindex [--full] [--no-embed], mdmem doctor,
mdmem eval --queries queries.yaml [--ablate].
Build and push
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.
Deployment (FluxCD on RKE2)
Deployment manifests are maintained in a separate fluxcd repo.
The mdmem manifests live under kubernetes/apps/apps/mdmem/ and are managed by FluxCD.
First install
- 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/**to the fluxcd repo. 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. While you are there, confirm Bifrost's
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 2560semantic_cacheplugin does not intercept/v1/embeddings— send two slightly different inputs and check the vectors differ. A cache hit there would return a stale vector for changed text and silently corrupt the index.uv run pytest -m livechecks this and the pooling/prefix behaviour. - Only then add the Bifrost MCP client configuration to Bifrost's
config.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. - 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:
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 the plan
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 pitfalls as pitfall 24. |
| 10 | — | UI_AUTH_DISABLED (new setting) |
Makes the "may ship without OIDC, but then without an HTTPRoute" rule explicit and loud instead of implicit. |
| 11 | queries.yaml with 50–100 real queries |
queries.example.yaml + --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 defaults stay as specified. |
Status
P0–P5 are implemented and tested (252 tests, plus 5 -m live ones that need the real
Bifrost endpoint). 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.
The implementation has been through a multi-agent review which found
35 defects — four of them data-losing (a YAML typo or a UTF-8 BOM destroying a note's
identity; Vault.delete() resolving the target from file contents and rmtree-ing
outside the vault; Vault.write() escaping the vault via a caller-supplied id; and
mdmem eval --ablate dropping the trigram table from the live index). All are fixed,
each with a regression test that was checked to fail when the fix is reverted. The
lessons are recorded as pitfalls 26–31.
Three tests were also found to be green for the wrong reason — a dense-retrieval test
whose query was the note body verbatim, a double-index test that slept less than the
watcher's debounce, and fusion tests that passed with RRF_K set to any value or _cc
returning a constant. Those now fail under sabotage.