mcp-zeromem
Can use Ollama (and other self-hosted OpenAI-compatible embedding servers such as llama.cpp, vLLM or LM Studio) as the embedding endpoint via ZEROMEM_EMBEDDING_URL, with connection testing from the CLI or Settings page and background re-embedding of stored turns.
Supports OpenAI-compatible embeddings APIs as the dense embedder, including the hosted OpenAI API, with configurable base URL, model name, API key, per-request timeout and query/document prefixes. The embedding dimension is learned from the first response, and switching embedders re-embeds the store in the background.
Uses a single SQLite database (WAL mode) as the conversational memory store, holding turns, entity spans, tokens, embeddings, the entity graph, temporal hierarchy and lexical statistics with persistent indexes. The same database file can be written by the server, a host-side CLI, or a separate ingest process, and turns are deduplicated by content or explicit UUID.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-zeromemwhat did we decide about the database setup?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-zeromem
Conversational memory for agents that costs zero tokens to maintain, over MCP.
Every turn an agent sees goes into one SQLite store. Recall over that store — entity graph, temporal hierarchy, lexical and dense search — runs with no LLM in the loop, so remembering is free and the only tokens spent are the ones the model spends reading what came back. The method follows the zeromem paper; the implementation here is a clean-room rewrite in Rust, loaded in-process by a Node MCP server, with persistent indexes so opening a large store does not mean rebuilding it.
Status: Parts 1 and 2 complete. The engine stores turns with their entity spans, tokens and embeddings in one transaction, keeps the entity graph, temporal hierarchy and lexical statistics on disk stamped with a generation counter, and recalls over four views (lexical, entity, dense, recent) fused by reciprocal rank and calibrated into a primary/supporting evidence set. The Node server exposes six MCP tools over Streamable HTTP and stdio plus a REST API. The admin UI shows the store's structure, not just its counts: an entity graph, a timeline of the temporal hierarchy, a session inspector with entity spans, a retrieval trace for any query, an embedding map, a side-by-side compare, growth and health charts, and the eval history. Quality is gated by a labeled eval set; a 50k-turn store opens in under 300 ms.
Quick start
cp .env.example .env
# Fill in MCP_ZEROMEM_TOKEN: openssl rand -hex 32
docker compose up -d --build
curl -s localhost:3200/api/status | jq .Then point a client at http://localhost:3200/mcp with
Authorization: Bearer $MCP_ZEROMEM_TOKEN. For Claude Code:
claude mcp add --transport http zeromem http://localhost:3200/mcp \
--header "Authorization: Bearer $MCP_ZEROMEM_TOKEN"The admin UI is at http://localhost:3200/; it asks for the same token.
Locally, without Docker (needs a Rust toolchain and Node 26):
npm install
npm run build:native # builds the addon; rerun after any Rust change
SECURE_LOCAL_NET=true npm run devRelated MCP server: identity-storage-mcp
Writing to the store
Three ways in, all landing in the same ./data/zeromem.db:
MCP write tools (
zeromem_remember,zeromem_ingest), for an agent to store what it decides is worth keeping.zminside the container, for hooks and bulk loads with no server hop:printf '{"session_id":"s1","speaker":"user","text":"we chose postgres"}\n' \ | docker compose exec -T mcp-zeromem zm ingestzmon the host, pointed at the bind-mounted directory (ZEROMEM_HOME=./data zm ingest). The store is SQLite in WAL mode, so a host writer and the server coexist; the server notices new turns on its next read.
Turns are deduplicated by content (or by an explicit uuid), so re-ingesting a
transcript is idempotent and reports duplicates rather than double-counting.
Remembering Claude Code sessions automatically
zm hook reads a Claude Code hook event ({"transcript_path", "session_id"})
from stdin and remembers the transcript's user and assistant turns. Wire it to
Stop and SessionEnd in ~/.claude/settings.json. Two ways to run it:
Host
zm(simplest; needscargo install --path crates/zeromem-core):{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "ZEROMEM_HOME=$HOME/mcp-zeromem/data zm --embedder none hook" }] }] } }--embedder nonekeeps the hook from loading a model or calling an endpoint: the turns land without vectors and the server embeds them with the store's embedder on its next read (they are searchable lexically in the meantime). Leave the flag off to have the hook embed inline with whatever the store uses, which needsZEROMEM_MODELSpointing atdata/modelsfor the ONNX model — or run it in the container:zmin the container, with the transcripts mounted read-only and the path prefix mapped:# docker-compose.override.yml services: mcp-zeromem: volumes: - ~/.claude:/host-claude:ro environment: ZEROMEM_HOOK_MAP: /home/me/.claude=/host-claude{ "type": "command", "command": "docker compose -f ~/mcp-zeromem/docker-compose.yml exec -T mcp-zeromem zm hook" }ZEROMEM_HOOK_MAPrewrites the host path in the event to where the container sees it; without it the hook fails with "reading transcript".
Set ZEROMEM_SESSION_ID on the server (or pass exclude_session) so recall
does not hand the current conversation back to itself.
Tools
Tool | What it does |
|
|
|
|
|
|
| Bulk JSON Lines, inline ( |
| Counts (turns, sessions, entities, edges, windows, episodes, embeddings), the embedder in use and whether it is the fallback; |
| Deletes a session's turns; requires |
Set ZEROMEM_READ_ONLY=true to drop the three write tools from the listing
entirely.
A client holding the curator token also gets five zeromem_curate_* tools and
the zeromem_curate prompt; see Curation.
How recall answers
A hit is one turn, and one turn is rarely the whole answer — the question that
prompted it and the sentence that finished it are the turns either side.
context: N attaches up to N same-session turns on each side of every hit, as
before and after (and as [before] / [after] lines under format: text).
Neighbours are attached to evidence, never ranked with it: evidence is
identical with and without context, so a neighbour never takes a slot from a
real hit, and a turn that is already ranked is not repeated as another hit's
context. Hidden turns stay hidden, leaving a gap rather than being backfilled.
format: text keeps a turn's line breaks — a numbered list stays a list — and
cuts a turn only past max_chars (default ZEROMEM_RECALL_TEXT_LIMIT, 2000
characters). A real cut is marked, and the marker names the call that returns the
rest: … [clipped: 2000 of 6120 characters — zeromem_read_session {around_turn: 418}]. That matters because a model handed a fragment with no marker concludes
its memory is incomplete and goes looking elsewhere; zeromem_read_session
never clips, since it is what the marker points at. format: json has never
truncated.
The same operations are on the REST API the UI uses (/api/status,
/api/sessions, /api/sessions/:id/turns, /api/recall, /api/recall/trace,
/api/ingest), under the same bearer token.
Configuration
Variable | Default | Meaning |
| — | Bearer token for |
| — |
|
|
| Hide the write tools |
|
| Characters per turn before |
| — | Bearer token for |
|
| Stdio server only: serve the curator tools |
|
| Where the store lives. Also exported to the engine as |
|
| The embedder for a new store: |
|
| Force an existing store onto |
| — | Base URL of an OpenAI-compatible embeddings API, e.g. |
| — | Model name sent to that endpoint, e.g. |
| — | Bearer token for the endpoint. Overrides a key saved from the Settings page; never returned by the API |
|
| Prepended to queries and to stored turns, for models that want them ( |
|
| Per-request timeout for the endpoint |
|
| Where the model is downloaded to (≈130 MB, once) |
| — | Session left out of |
|
| The eval runs the Eval page charts |
|
| Listen port inside the container (published as 3200) |
GET /api/status is unauthenticated liveness and is what the healthcheck polls.
It carries embedder, embedder_is_fallback, embedder_active and
embedding_backlog; the UI shows a banner when the fallback is in use, because
hashed vectors make dense recall a spelling match rather than a meaning match,
and a progress line while turns are waiting for a vector.
Choosing an embedder
The store owns the embedder. The first process to open a store writes the
embedder it was started with into the store's meta; from then on every
process that opens it — the server, a stdio MCP session, a host-side zm —
follows the store, and a differing ZEROMEM_EMBEDDER is logged and ignored.
That is what keeps recall coherent when three processes share one file.
Three kinds of embedder are available:
Kind | Name in | Notes |
Built-in BGE-small (ONNX) |
| 384 dims, in-process, ≈130 MB download on first use |
Hash fallback |
| Deterministic bag-of-words, no download; a spelling match, not a meaning match |
OpenAI-compatible endpoint |
| Anything serving |
Change it from the Settings page: pick the kind, fill in the endpoint, Test connection (reports the dimension and latency without touching the store), then Apply. The switch is one transaction — spec written, vectors dropped, generation bumped — and the server's embed worker re-embeds every turn in the background in batches of 256. Recall keeps answering from the lexical and entity views while the backlog drains; the progress bar on Settings, the Overview and the Embeddings page shows how far along it is. Headless hosts do the same with the CLI:
zm embedder show
zm embedder test --kind openai --url http://npu-box:11434/v1 --model nomic-embed-text
zm embedder set --kind openai --url http://npu-box:11434/v1 --model nomic-embed-text --api-key-stdin < key.txt
zm embedder reembed # drop every vector and re-make them with the current embedder
zm embedder drain # re-embed the backlog now instead of waiting for the serverWhen a switch stalled half way, or the model behind an endpoint was replaced
under the same name, Re-embed all turns on the Settings page (or
zm embedder reembed) starts over with the embedder the store already names.
It is tested first, as a switch is, so an endpoint that still fails is
reported and no vector is dropped. The Stored data card below it clears
the store: Clear vectors drops every vector without testing anything
(the worker re-embeds them), and Clear all memory forgets every turn,
session, entity and summary, after you type clear. Both keep the embedder
settings, bump the generation so every other process reloads, and are
refused under ZEROMEM_READ_ONLY (POST /api/settings/embedder/reembed,
POST /api/settings/clear with {"scope": "embeddings" | "memory"}).
An API key entered on the Settings page is saved in the store's meta table
in plain text, so the stdio process and a host zm can use the same
endpoint. ZEROMEM_EMBEDDING_API_KEY in the environment overrides it wherever
it is set; the page says which one is in use and the API never returns either.
Leave the key field blank to keep the stored one.
If a process cannot build the store's embedder — no model files at hand, the
endpoint is down — it runs without a dense view and says so
(embedder_active: false, embedder_warning in the status), storing new turns
vectorless for the worker rather than silently substituting the hash embedder.
A remote endpoint that starts failing trips a breaker for 30 s so a recall
under load never waits out the timeout call after call.
Seeing the store
The admin UI at / (same port as /mcp) has a page per structure the engine
keeps. Each chart has a table twin, so every number on screen can be read as
text.
Page | What it shows | Read behind it |
Overview | Counts, embedder, turns per day and the running total |
|
Sessions → inspect | A session's turns in order with entity spans highlighted; click one to follow it across the session, or jump to it in the graph |
|
Graph | Force-directed entity graph: node size is degree, edge weight is shared turns; focus an entity and expand N hops; click a node for its neighbours and the turns recall finds for it |
|
Timeline | Sessions as lanes, windows as bands, episodes as inner segments; brush a range for the turns beneath it |
|
Recall → Trace | One query through every stage: profile, the views it was routed to with their raw candidates, the fused list, what calibration dropped and why, the evidence |
|
Embeddings | 2-D PCA of the dense vectors, coloured by session; drop a query on the map to ring its nearest turns. Under the hash fallback the structure collapses, which is the point |
|
Compare | The same query with two settings side by side (top-k, session filters); pin side A, change B, and read the diff of the evidence sets |
|
Eval | recall@k / MRR / nDCG per commit, one line per corpus × embedder |
|
Health | Recall latency percentiles and throughput per minute for the last hour, cold-open time, errors, then the raw status payload |
|
Settings | The store's embedder: current name, kind and dimension, the re-embed progress, a form to test and switch to the ONNX model, the hash fallback or an OpenAI-compatible endpoint, re-embedding with the current one; and clearing the vectors or the whole memory |
|
Curation | Curator runs with the actions each took and why; undo one action or a whole run; the aliases and blocklist in force |
|
Every snapshot endpoint is capped (limit) so a 50k-turn store never lands
whole in a browser tab; the graph page says when it cut at the node cap. The
projection is computed in Rust and cached per generation. Health counters are
in-process and reset with the server.
The eval page reads docs/eval/history.jsonl (override with EVAL_HISTORY).
scripts/record-eval.sh [label] runs the harness and appends one line per
corpus × embedder, stamped with the commit; commit the file with the change
that moved the numbers.
Curation
The store only grows: near-duplicates, pasted tool output and "ok, thanks" compete in recall, one person is split across "Maya" and "Maya Okafor", and an old fact ranks level with the one that replaced it. A curator is any MCP client, most usefully an LLM agent on a schedule, that cleans this up. The engine stays deterministic: it finds candidates cheaply and applies reversible operations. The judgement, the only part that costs tokens, lives in the agent.
Curation never deletes. Turns are immutable; every action is logged with its
reason and can be undone from the Curation page. Purging is left to a human
(zeromem_forget_session, Settings → Stored data).
Op | Effect on recall |
| The turn is left out of recall (still shown, flagged, in session reads) |
| The old turn hands its score to its replacement, which is recalled in its place even when it shares few words with the question; the old turn is halved and folds away when both make the answer. Temporal questions ("what did … used to …") are left alone |
| Mentions of the alias count as the canonical entity in the graph and entity stats |
| The name is not an entity at all |
| A new turn ( |
| Records the run's summary and advances the cursor the finders start from |
The curator surface
Tool | What it does |
| Past runs, their action counts and summaries, the cursor, and the limits in force |
|
|
|
|
|
|
|
|
The zeromem_curate prompt carries the procedure
(docs/curator-playbook.md), so a client gets it from the
server: orient, gather candidates since the cursor, judge (when unsure, skip), dry
run, apply in batches, finish with run_end. Its optional focus argument limits a
run to some kinds.
Access and limits
Settings → Curator holds all of it:
The curator token. Generate one (shown once) or paste your own. A request to
/mcpbearing it gets the normal tools plus the curator surface; the normal token never sees them, so an ordinary agent keeps its six-tool budget. The token opens/mcponly, not the REST API.MCP_ZEROMEM_CURATOR_TOKENoverrides the stored one, and the page then says so and will not change it.Serve the curator tools to every client. Off by default; on, every
/mcpclient and the stdio server get them, token or not.Limits. Actions per call (default 100), actions per run (300), and a minimum turn age (24 h) so a live conversation is never curated under the user. A reason is required on every action.
ZEROMEM_READ_ONLY drops the curator's write tools like the others. For the stdio
server, ZEROMEM_CURATOR=true serves the curator tools.
Scheduling a curator
The server runs no agent. Anything that speaks MCP can be the curator, from this
machine or another; it needs the /mcp URL and the curator token, no shared disk.
Notes it writes are embedded by this server.
A Claude Code scheduled task, or claude -p under cron, with an MCP config that
points at the store:
{
"mcpServers": {
"zeromem": {
"type": "http",
"url": "http://memory-host:3200/mcp",
"headers": { "Authorization": "Bearer ${ZEROMEM_CURATOR_TOKEN}" }
}
}
}# Nightly at 03:30: run the playbook the server serves as its zeromem_curate prompt.
30 3 * * * claude -p "/mcp__zeromem__zeromem_curate" --mcp-config /etc/zeromem/curator.json --allowedTools "mcp__zeromem__*"An Agent SDK script does the same from code: fetch the prompt, hand it to the agent as its task, and let it call the tools.
import { query } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const url = 'http://memory-host:3200/mcp';
const headers = { Authorization: `Bearer ${process.env.ZEROMEM_CURATOR_TOKEN}` };
const client = new Client({ name: 'curator', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } }));
const { messages } = await client.getPrompt({ name: 'zeromem_curate' });
await client.close();
const playbook = messages.map((m) => (m.content.type === 'text' ? m.content.text : '')).join('\n');
for await (const message of query({
prompt: playbook,
options: {
mcpServers: { zeromem: { type: 'http', url, headers } },
allowedTools: ['mcp__zeromem__*'],
},
})) {
if (message.type === 'result') console.log(message.subtype);
}Review a run on the Curation page. Undoing a run restores recall to what it was.
How recall works
Ingest writes, in one transaction, the turn, its entity spans (names, dates
and quantities found by shape, no model), its FTS5 tokens and its embedding —
or no embedding, when the writer has no dense view; every read then embeds
one batch of the backlog first, and the server's worker drains the rest.
The entity co-occurrence graph and the temporal hierarchy (sessions → windows
→ episodes, split on silences and on entity drift between windows) are
persisted next to them; appends update them incrementally, forget rebuilds
the aggregates from the per-turn artifacts and bumps a generation stamp so
every other process holding the store reloads. Opening a store is a read,
never a recomputation.
A query is profiled (tokens, entities, temporal cues, question form), routed
to the views that can answer it, and each view's ranked candidates are fused
by reciprocal rank. The fused list is calibrated: candidates under 0.35 are
dropped, those at 0.7 or above are primary, the rest supporting. With
detail: full the result carries the route and per-turn sources; the
/api/recall/trace endpoint (and zm query --trace) returns every stage.
Numbers
Hash embedder, one core, npm run bench (cargo run --release --example bench); CI keeps the JSON as an artifact.
turns | ingest | cold open | RSS after open | recall p50 | recall p95 | store |
1k | 19k turns/s | 4 ms | 19 MB | 2.2 ms | 4.1 ms | 2.4 MB |
10k | 14k turns/s | 58 ms | 53 MB | 18 ms | 32 ms | 24 MB |
50k | 9.2k turns/s | 281 ms | 126 MB | 83 ms | 121 ms | 121 MB |
Recall is linear in store size (brute-force cosine over every vector plus a full lexical scan); an ANN index is the follow-up when stores pass 100k turns.
Eval (cargo test -p zeromem-core --test eval, recall@5 / MRR / nDCG@5 over
the labeled fixtures):
corpus | hash-384 | bge-small-en-v1.5 |
small (10 queries) | 0.90 / 0.83 / 0.84 | 0.90 / 0.90 / 0.90 |
large (323 queries) | 0.71 / 0.90 / 0.67 | 0.88 / 0.97 / 0.82 |
The floors in tests/eval.rs sit a little under these; raising them is how
retrieval improvements are locked in. With ZEROMEM_EMBEDDING_URL and
ZEROMEM_EMBEDDING_MODEL set the eval also scores that endpoint, recorded but
never gated, so scripts/record-eval.sh gives your model its own line on the
Eval page.
Comparing against upstream
The upstream binary is a black box here (see the clean-room policy). To compare rankings, run both over the same fixture turns and score the lists:
scripts/rank.sh large 5 > ours.jsonl # our answers, one {"id","ranked":[uuid…]} per line
# …run the other ranker over crates/zeromem-harness/fixtures/large/turns.jsonl
# and emit the same shape as theirs.jsonl
cargo run -q -p zeromem-harness -- compare large ours.jsonl theirs.jsonlThat prints both sides' metrics against the labels, plus top-k overlap and
Spearman rank agreement. The adapter that drives upstream's binary is not in
this repo yet; CI uploads ours.jsonl for every commit so it can be run
against any version. The comparison is tracked, never gated: the labels are
the gate.
Layout
crates/zeromem-core/ the engine (Rust lib) + the `zm` CLI; tests/ holds the oracle + properties
crates/zeromem-harness/ corpus generator, labeled queries, eval metrics; fixtures/ is generated
crates/zeromem-node/ napi-rs addon → @mcp-zeromem/native
shared/src/ zod DTOs shared by server and app
server/src/ Express 5: /api routes (viz/ for the charts), /mcp gateway, stdio bin, metrics.ts
app/src/ React admin UI; routes/ is one file per page, components/viz/ the chart kit
docs/eval/ history.jsonl, the recorded eval runs the Eval page reads
scripts/ smoke-stdio.sh, rank.sh, record-eval.shDevelopment
npm run lint && npm run typecheck && npm test && npm run test:rust
npm run build # addon → shared → server → app
./scripts/smoke-stdio.sh # JSON-RPC over the stdio bin
npm run bench # cold open / RSS / recall latency at 1k, 10k, 50k turns
scripts/record-eval.sh # append this commit's eval metrics to docs/eval/history.jsonl
npm run routes:gen -w app # regenerate app/src/routeTree.gen.ts after adding a route fileCharts follow the dataviz house rules: a fixed eight-slot categorical
palette (--viz-series-1..8 in app/src/index.css, validated for both
themes) that is never cycled, one axis per chart, thin marks, a legend for
two or more series, a table view for every chart, and the previous render
held at reduced opacity while a refetch is in flight.
npm run test:rust downloads the embedding model into target/models on
first run (set ZEROMEM_SKIP_ONNX=1 to skip the ONNX halves of the
reference-vector and eval tests). UPDATE_GOLDEN=1 rewrites the retrieval
snapshots under crates/zeromem-core/tests/golden/ after an intended ranking
change; review the diff.
The repo is a clean-room rewrite: no code, scripts, fixtures or schema from the
upstream project are used, and none may be added. See the policy in
AGENTS.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Persistent memory for AI agents — log and recall conversation context over MCP.
Shared, governed long-term memory for AI agents across tools and sessions via MCP and REST.
- memnodeOAuthdev.memnode
Persistent, inspectable memory for AI agents with lineage, correction, and a hosted MCP endpoint.
Persistent memory for AI agents across Claude, ChatGPT and any MCP client.
Related MCP Servers
- AlicenseAqualityDmaintenancePersistent memory for AI agents. Store, recall, and share knowledge across sessions with five MCP tools: remember, recall, context, forget, and share. Includes semantic search and agent/user/org scoping.53Apache 2.0
- AlicenseAqualityBmaintenanceProvides persistent, inspectable memory storage for AI agents using SQLite. Agents can store, recall, and search memories across sessions via three MCP tools.3MIT
- AlicenseNot gradedqualityAmaintenanceProvides persistent, cooperative memory for LLMs via MCP, with SQLite storage and tools for capturing, recalling, consolidating, crystallizing, and forgetting memories across sessions.3MIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible agents to automatically distill conversations into durable local memory and recall relevant context on demand through seven MCP tools, using user-configurable OpenAI-compatible endpoints for extraction and embeddings.MIT