Skip to main content
Glama

memsilo

memsilo is a self-hostable, multi-user memory server for LLM applications. It puts Mem0 and Qdrant behind an MCP endpoint where every caller holds their own bearer token and is only ever shown their own memories.

It is built for one situation: you run a service whose end customers are people (often vulnerable, often non-technical) who reach an LLM through something like a text message. Your backend talks to the model on their behalf and needs a long-term memory per customer. Your backend holds one token per customer and passes it to memsilo; memsilo stores, searches and deletes only that customer's memories. That separation is logical (one shared Qdrant collection, filtered by user id), not cryptographic; see below and docs/security.md.

What memsilo is not

Read this before you rely on it.

  • Not a finished SaaS. It is a small server you operate yourself. You own backups, upgrades, monitoring and incident response.

  • No TLS built in. It speaks plain HTTP. Put a TLS-terminating reverse proxy in front of it (see TLS and reverse proxy).

  • No admin API and no self-signup. Customers are created by an operator running a command on the server. There is no HTTP endpoint that creates, lists or reads other users.

  • Logical isolation only. All customers share one Qdrant collection, separated by a user_id filter. There is no per-customer encryption or per-customer database.

  • Your backend is the single point of trust. It holds every customer's token. If it is compromised, every customer's memories are exposed.

  • No rate limiting. Do that in your proxy or backend.

  • No Helm chart or Kubernetes manifests in v1. Docker Compose is the supported deployment, with two persistent volumes: Qdrant storage and ./data.

The full threat model, and what the isolation does and does not protect against, is in docs/security.md.

Related MCP server: memlawb

What memsilo adds to Mem0, Qdrant and mcp

memsilo is a thin layer. Mem0, Qdrant and the mcp Python SDK do the real work: fact extraction, embeddings, storage, search and the protocol. They are general-purpose libraries, and a multi-tenant access layer is not their job; nothing below is a defect in them. The table says what memsilo puts around them so that many customers can share one deployment. Every statement about a library was checked against the versions pinned in poetry.lock (mem0ai 2.0.20, mcp 2.1.1, qdrant-client 1.19.0, with the Qdrant 1.19.0 server image); other versions may behave differently.

Concern

The libraries on their own (pinned versions)

What memsilo adds

Where to verify

Authentication

mcp has building blocks for OAuth bearer tokens (a token verifier interface and auth middleware) and leaves issuing and storing tokens to the application. Mem0 and Qdrant have no notion of a calling end user.

One bearer token per customer on every HTTP request; a missing, unknown or revoked token gets 401. Only /health is open.

mcp_server.py, tests/test_auth.py

Identity binding

Mem0's add, search, get_all and delete_all take the user id from their caller, who chooses it.

No tool has a user_id parameter. The id comes from the token, and every Mem0 call is scoped to it.

tests/test_isolation.py

Session ownership (SSE and streamable HTTP)

mcp binds a session to the credential that created it and refuses any other, once the application puts an authenticated user into the request.

Puts the token's user there, so that check runs, and adds a second, independent check in its own middleware: 403 for any session id the server did not issue to this user.

tests/test_session_hijack.py, tests/test_sse_e2e.py, tests/test_transport_e2e.py

Delete ownership

Mem0's Memory.delete(memory_id) deletes by id; it does not check whose memory it is, and it keeps the deleted text in its history table.

delete_memory refuses anything that is not a well-formed memory id, reads the memory and deletes it only if it belongs to the caller, then purges that memory's history rows and checks that none is left.

tests/test_delete_ownership.py, tests/test_isolation.py, tests/test_tool_hardening.py

Identity keys in metadata

Mem0's add drops user_id, agent_id, run_id and actor_id from caller metadata and logs a warning.

add_memory refuses such metadata with an error the caller sees.

tests/test_isolation.py

Empty extraction

When the LLM's reply is empty (for example a reasoning model that spent its whole token budget thinking), Mem0 2.0.20 stores nothing and reports no error.

add_memory still answers stored: 0, but adds a warning and logs one on the server (without the text).

tests/test_tool_hardening.py

Result limits

Mem0's search and get_all return at most 20 results unless top_k is given, and search silently ignores keyword arguments it does not know, such as limit=.

Tools take limit, clamp it to 1-200 and pass it to Mem0 as top_k.

tests/test_isolation.py

Telemetry

Mem0 sends anonymous usage telemetry unless MEM0_TELEMETRY turns it off. onnxruntime 1.29, which fastembed uses for embeddings, starts Microsoft telemetry when it is imported. The Qdrant server reports telemetry unless it is disabled.

Mem0's off by default; onnxruntime's and Hugging Face Hub's always off, set before Mem0 (and through it onnxruntime) is imported, in the server and the CLI; the compose Qdrant runs with telemetry disabled. See Network traffic.

tests/test_memory.py, tests/test_manage_users.py, tests/test_deploy.py

Erasing a customer

Mem0's delete_all(user_id=...) removes the memories from Qdrant, but writes a history row holding each deleted text, and leaves the cached conversation messages in its SQLite database. Qdrant's own deletes are soft: the old payload bytes can stay in its storage files for a while.

erase deletes the memories (expired ones too), purges the history rows of the memory ids it collected and that customer's cached messages, then checks in the same run that none of them is left and fails loudly, listing the ids, if any is. It refuses malformed ids such as *, which Mem0's Qdrant store treats as "match any user". It does not, and cannot, scrub Qdrant's storage files: see Erasing and exporting a customer.

memory.py, tests/test_manage_users.py

Export

Mem0's open-source Memory class lists memories with get_all and has no export command. (The same package's client for the hosted Mem0 platform, MemoryClient, has an export API; memsilo does not use it.)

export <user_id> prints one customer's current memories, expired ones included, as JSON.

tests/test_manage_users.py

User store

None.

Validated or generated opaque ids, tokens shown once and stored only as hashes, atomic writes under a file lock, rotate and revoke, bulk onboarding from CSV, --json output.

users.py, tests/test_users.py

Configuration

Mem0 takes a Python dict.

One precedence order (environment, config.yaml, default). Unknown keys, wrong types, out-of-range numbers and empty values stop the server at startup; secrets come from the environment only.

config.py, tests/test_config.py

Embedding size

Mem0's Qdrant store reuses an existing collection without comparing its vector size with the embedder's.

Refuses to start when the two differ.

tests/test_memory.py

Backend errors

Mem0 and Qdrant exceptions can carry internal addresses and response bodies.

Tools log the exception on the server and return only internal error.

tests/test_tool_errors.py

Docker

The Qdrant image runs without an API key unless you configure one.

Qdrant requires QDRANT_API_KEY (compose refuses to start without it) and is not published on the host; non-root image (uid 10001) with both embedding models baked in and no downloads at runtime; memsilo on loopback only; ./data mode 700.

Dockerfile, docker-compose.yml, tests/test_deploy.py

The repository itself

Not applicable.

Tests that the documentation matches the code (configuration tables, variables, links) and that no private strings are committed.

tests/test_docs.py, tests/test_no_private_strings.py

What memsilo does not add

  • Physical isolation. Every customer's memories are in one Qdrant collection. Mem0's Qdrant store filters them by the user_id memsilo passes; nothing is encrypted or stored per customer.

  • An admin API, rate limiting or TLS. See What memsilo is not.

  • Anything about memory itself. Fact extraction, search, storage, embeddings and the MCP protocol are Mem0, Qdrant, fastembed and mcp, unchanged, with their behaviour and limits (for example the 4 MiB request-body cap of the mcp transports). In our tests Mem0 2.0.20 only added facts: "switched to insulin" was stored next to "takes metformin", not instead of it.

Quick start (Docker)

Usually a few minutes, depending on your network. You need Docker with the Compose plugin and an LLM: an Anthropic API key by default, or a local or OpenAI-compatible server (see Using a local or OpenAI-compatible LLM). Mem0 uses it to extract facts from what customers say. Docker Compose is the supported deployment; it uses two persistent stores: the Qdrant storage volume and the ./data directory.

git clone https://github.com/romagn/memsilo.git
cd memsilo

cp .env.example .env              # then put your key in .env: ANTHROPIC_API_KEY=...
# A random key for the bundled Qdrant (it refuses requests without one):
echo "QDRANT_API_KEY=$(openssl rand -hex 32)" >> .env
# (no openssl? python3 -c "import secrets; print(secrets.token_hex(32))")
cp config.example.yaml config.yaml

docker compose pull               # the published image: a few hundred MB, no build needed
docker compose up -d
# wait until it is ready (up to 2 minutes)
for i in $(seq 60); do curl -fsS http://127.0.0.1:8100/health && break; [ "$i" = 60 ] && echo "not ready after 2 minutes: docker compose logs memsilo"; sleep 2; done
# ok

The image is rmagnorecon/memsilo on Docker Hub, built by this repository's CI from each version tag (0.1.1, plus latest; linux/amd64 only). The compose file pins the version that matches the checkout, so git pull moves you to the matching image. To use another tag or your own registry set MEMSILO_IMAGE (for example MEMSILO_IMAGE=registry.example.com/memsilo:0.1.1). To build the image from your checkout instead (another platform, or local changes), skip docker compose pull and run docker compose up -d --build: usually a few minutes, depending on your network. That build is tagged with the same name, replacing a pulled copy.

docker compose up -d returns as soon as the containers have started, before the server inside is listening: a curl straight away can fail with Connection reset by peer. Poll /health as above (in our test it answered about three seconds later), in scripts too. If it is not ready after two minutes the loop stops and says so; docker compose logs memsilo shows why (for example a missing key or an embedding-size mismatch).

config.yaml must exist before docker compose up: the compose file mounts it read-only and refuses to start without it (bind source path does not exist). .env is required too: compose refuses to start without QDRANT_API_KEY (required variable QDRANT_API_KEY is missing a value), and with the default anthropic provider the server refuses to start without ANTHROPIC_API_KEY. .env.example leaves QDRANT_API_KEY= empty; the echo line above appends a value, and the last one in the file wins.

Create a customer and get their token (shown once, never stored in clear):

docker compose exec memsilo python scripts/manage_users.py --json add --display-name "cust 001"
{
  "user_id": "cust_0123456789abcdef",
  "display_name": "cust 001",
  "token": "<token>"
}

Check that authentication works. Without a token you get 401; with one, the MCP initialize handshake succeeds:

curl -sS -X POST http://127.0.0.1:8100/mcp \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

The compose file publishes memsilo on 127.0.0.1:8100 only. Qdrant is not published on the host, but that does not make it unreachable: its address on the Docker bridge network is routable from the host itself, so any local user or process, and other containers on the same Docker host, can connect to it. The API key is what protects it. Without the key Qdrant answers 401; with it, it serves every customer's memories, so keep .env as secret as the data. Nothing is reachable from other machines until you put a proxy in front of memsilo.

If you need to look at Qdrant for debugging, add a mapping to the qdrant service temporarily and remove it afterwards:

    # ports:
    #   - "127.0.0.1:6333:6333"   # DEBUGGING ONLY

Every request still needs the key (curl -H "api-key: <key>" ...). While that port is open, anyone on the host who has the key can read and change every customer's memories.

Stop with docker compose down (data survives; docker compose down -v also deletes the Qdrant volume, that is, every memory; it does not touch ./data).

Upgrading

git pull
docker compose pull               # or: docker compose up -d --build, to build from source
docker compose up -d
for i in $(seq 60); do curl -fsS http://127.0.0.1:8100/health && break; [ "$i" = 60 ] && echo "not ready after 2 minutes: docker compose logs memsilo"; sleep 2; done

The Qdrant volume and ./data persist across the upgrade: customers, tokens and memories stay. Back up both first (see Where the data lives).

Configuration

Settings come from three places. Highest wins: environment variable, then config.yaml, then the built-in default. Unknown keys, wrong types, an out-of-range server.port or embedder.dims, an unknown transport, and an empty or blank value for any setting are errors that name the offending key (only MEMSILO_DOTENV and the two secrets may be empty, with the meanings below). config.yaml is looked up in the working directory (/app/config.yaml in the container) unless MEMSILO_CONFIG names another file. Every key, with its default, is in config.example.yaml.

Key

Environment variable

Default

Meaning

server.host

MEMSILO_SERVER_HOST

127.0.0.1

Bind address for the HTTP transports. Compose sets 0.0.0.0.

server.port

MEMSILO_SERVER_PORT

8100

Port (1-65535).

server.transport

MEMSILO_TRANSPORT

stdio

stdio, sse or streamable-http. Compose sets streamable-http.

qdrant.url

MEMSILO_QDRANT_URL

http://localhost:6333

Qdrant address. Compose sets http://qdrant:6333.

qdrant.collection

MEMSILO_COLLECTION

memsilo

Collection that holds every customer's memories.

llm.provider

MEMSILO_LLM_PROVIDER

anthropic

Mem0 LLM provider.

llm.model

MEMSILO_LLM_MODEL

claude-haiku-4-5-20251001

Model used to extract facts.

embedder.provider

MEMSILO_EMBEDDER_PROVIDER

fastembed

Embedding provider (runs locally by default).

embedder.model

MEMSILO_EMBEDDER_MODEL

BAAI/bge-small-en-v1.5

Embedding model.

embedder.dims

MEMSILO_EMBEDDER_DIMS

384

Vector size; must match the model.

storage.users_file

MEMSILO_USERS_FILE

data/users.json

Token store: hashes of tokens, plus display_name and created_at in clear.

storage.history_db

MEMSILO_HISTORY_DB

data/history.db

Mem0's SQLite history database. Holds memory text.

telemetry

MEMSILO_TELEMETRY

false

Mem0's anonymous usage telemetry. Off unless you turn it on.

(env only)

MEMSILO_DEFAULT_USER_ID

default

The user the unauthenticated stdio transport acts as. Must be a valid user id.

(env only)

MEMSILO_CONFIG

config.yaml

Path of the config file. If set, the file must exist.

(env only)

MEMSILO_DOTENV

./.env

Which .env file to read (see below). An empty value disables loading.

(secret)

ANTHROPIC_API_KEY

none

LLM key; required when llm.provider is anthropic.

(secret)

QDRANT_API_KEY

none

Qdrant API key. Required by the provided compose file, which sets the bundled Qdrant to require it. Empty means no key (only for a Qdrant of your own that needs none).

.env loading. On a bare-metal run, memsilo reads .env from the current working directory only, never from a parent directory, and real environment variables always win over it. Set MEMSILO_DOTENV to a path to read that file instead, or to an empty value to read none. (With compose, .env is passed in by env_file and nothing is read inside the container.)

Secrets come from the environment only. A key in config.yaml whose name looks like a secret is rejected. With compose, put them in .env. In compose the variables set in docker-compose.yml (MEMSILO_QDRANT_URL, MEMSILO_TRANSPORT, MEMSILO_SERVER_HOST) override config.yaml, so changing server.host, server.transport or qdrant.url in config.yaml has no effect until you edit the compose file. Changing server.port (or MEMSILO_SERVER_PORT) also breaks the compose 8100:8100 port mapping; edit both.

Changing the embedder needs a new collection. At startup memsilo compares embedder.dims with the existing collection's vector size and refuses to start if they differ. Pick a new qdrant.collection name (old memories stay in the old collection and are not migrated).

Mem0 sends memory text to the configured LLM provider (Anthropic by default) every time a memory is added. Anything a customer tells your service can leave your server that way. With the provided image that is the only outbound connection memsilo makes; see Network traffic.

Using a local or OpenAI-compatible LLM

Mem0's openai provider works with any server that speaks the OpenAI chat API, such as Ollama. No code change is needed; set these in .env:

MEMSILO_LLM_PROVIDER=openai
MEMSILO_LLM_MODEL=<model>                     # for example a small instruction model
OPENAI_BASE_URL=http://ollama-host:11434/v1   # your server's OpenAI-compatible endpoint
OPENAI_API_KEY=unused                         # any non-empty value if the server needs none
ANTHROPIC_API_KEY=                            # not needed with this provider

(ANTHROPIC_API_KEY is only required when llm.provider is anthropic.) With compose the memsilo container must be able to reach that address. Things to know:

  • Memory text goes to that endpoint, the same as it would to Anthropic. Use plain HTTP only on a network you trust.

  • Disable reasoning ("thinking") on reasoning models. If the model spends its token budget reasoning, the reply comes back empty and Mem0 stores nothing; memsilo then answers stored: 0 with a warning. memsilo cannot ask Mem0 to turn reasoning down: Mem0 2.0.20 sends reasoning_effort only for models it recognises as reasoning models, and memsilo passes Mem0 only the model name and key. Turn reasoning off on the server side (for example in the model's settings), or use a non-reasoning model.

  • Extraction quality is the model's. Small models paraphrase or pad facts.

  • Tested once, with synthetic data: Ollama 0.33.2 serving qwen3.5:4b, about 6 to 9 seconds per add_memory.

Adding customers

The CLI is scripts/manage_users.py. Run it inside the memsilo container (docker compose exec memsilo ...), see Where the data lives. Global options (--json, --users-file PATH) go before the subcommand.

# Opaque, generated id (recommended): cust_ plus 16 random hex characters
docker compose exec memsilo python scripts/manage_users.py --json add --display-name "cust 001"

# Your own id: letters, digits, "_" and "-", starting with a letter or digit, up to 64 chars
docker compose exec memsilo python scripts/manage_users.py add alice --display-name "cust 002"

# Many at once from a CSV (header: user_id,display_name; leave user_id empty to generate one)
docker compose exec memsilo python scripts/manage_users.py --json bulk-add /tmp/users.csv

docker compose exec memsilo python scripts/manage_users.py list              # never prints tokens
docker compose exec memsilo python scripts/manage_users.py --json rotate <user_id>   # new token, old one stops working
docker compose exec memsilo python scripts/manage_users.py revoke <user_id>          # token stops working

A bulk-add file is UTF-8 CSV whose first line is exactly user_id,display_name. Leave user_id empty to have one generated. Put a value that contains a comma in double quotes:

user_id,display_name
,cust 002
bob,"cust 003, pilot"

list prints one line per user, with no header: user_id, display_name, status (active or revoked) and created_at (UTC). It never prints tokens.

bulk-add reads a file inside the container. Copy your CSV in with docker compose cp users.csv memsilo:/tmp/users.csv and pass /tmp/users.csv. The whole file is validated before anything is written. --json is honoured by add, bulk-add, rotate, erase and (always) export; list and revoke print plain text.

Tokens look like ms_<user_id>_<43 random characters>. They are shown once; only a hash is stored. A user_id is unique for the life of the store, revoked or not; to give a revoked customer a working token again use rotate.

Use opaque ids and a non-identifying --display-name. Do not use phone numbers, names or email addresses as user_id. Ids appear in the token itself, in users.json and in Qdrant. Omit the id and memsilo generates one; keep the mapping from id to real person in your own backend. The same goes for --display-name: use an internal customer number such as cust 001, never a real name or phone number, because the label is stored in clear in users.json and survives erase (see below).

Connecting a client

Your backend (or any MCP client) connects to /mcp with the customer's token as a bearer token. For the sse transport the endpoint is /sse instead. Through a proxy:

claude mcp add --transport http memory https://memory.example.com/mcp \
  --header "Authorization: Bearer <token>"

Tools exposed (all scoped to the token's user; none takes a user_id):

Tool

What it does

search_memory(query, limit=10)

Semantic search. limit is clamped to 1-200.

add_memory(text, metadata="{}")

Mem0 extracts facts with the LLM and stores them. Mem0 2.0.20, in our tests, only adds: a new fact does not replace an older one it contradicts (both stay). When nothing was extracted the reply has stored: 0 and a warning. metadata must be a JSON object and may not contain user_id, agent_id, run_id or actor_id.

get_all_memories(limit=50)

List memories. limit is clamped to 1-200.

delete_memory(memory_id)

Delete one of the caller's memories and purge its history rows from history.db. Another user's id, or anything that is not a memory id, gets not found or not owned by you. If the delete worked but the purge did not, the reply has deleted: true and an error, and the server log says why.

memory_stats()

Count and a short sample.

add_memory calls the LLM, so it needs a working LLM provider.

TLS and reverse proxy

memsilo does not terminate TLS. Run a proxy on the host in front of 127.0.0.1:8100. With Caddy, which obtains certificates automatically:

memory.example.com {
    reverse_proxy 127.0.0.1:8100
}

Do not publish port 8100 to the internet directly, and do not expose Qdrant at all.

Run it through the provided compose file, or rewrite the Host header. If memsilo is bound to its default 127.0.0.1 (for example when you run python mcp_server.py yourself on the same machine as the proxy), the underlying mcp library enables DNS-rebinding protection that only accepts loopback Host headers. A proxy that forwards the public host name then gets HTTP 421 Invalid Host header (and a request carrying a non-loopback Origin header gets 403). Running python mcp_server.py directly also needs MEMSILO_TRANSPORT=streamable-http, since the default transport is stdio. We tested this against a loopback-bound instance:

Host: 127.0.0.1:8101           -> 200
Host: localhost:8101           -> 200
Host: memory.example.com       -> 421 Invalid Host header
Host: memory.example.com:443   -> 421 Invalid Host header

The provided docker-compose.yml sets MEMSILO_SERVER_HOST=0.0.0.0 inside the container, which disables that check (a request with Host: memory.example.com to the compose container returned 200), and publishes the port on the host's loopback only, so the proxy works unchanged. If you bind to loopback yourself, make the proxy send a loopback Host instead (in Caddy: reverse_proxy 127.0.0.1:8100 { header_up Host 127.0.0.1:8100 }).

Where the data lives

  • Qdrant volume (qdrant-storage): every customer's memory text and vectors.

  • ./data (mounted at /app/data): users.json (token hashes plus display names), users.json.lock, and history.db (Mem0's history database, which also contains memory text).

The container runs as a non-root user with the fixed uid/gid 10001. users.json is replaced atomically on every change, so its owner becomes whoever ran the command. A one-shot data-init service in the compose file therefore sets ./data to 10001:10001 mode 700 on every docker compose up, with no sudo on the host. Two consequences:

  • Always run the CLI as the container user: docker compose exec memsilo ..., never -u root. We confirmed that a users.json written as root makes the server fail with PermissionError (HTTP 500) until ownership is repaired; docker compose up -d repairs it.

  • Other users on the host cannot read ./data, and neither can you without sudo. Read it through the container (docker compose exec memsilo ls -l /app/data).

Starting over, and deleting the checkout

After the first docker compose up, ./data on the host belongs to uid 10001 with mode 700. For your own user on the host that means:

  • git status in the checkout prints warning: could not open directory 'data/': Permission denied. Git cannot look inside to check for untracked files. It is harmless: everything in data/ except .gitkeep is git-ignored.

  • rm -rf data/* and deleting the checkout fail with Permission denied, because your shell cannot even list data/. (git clean skips the git-ignored data/ and only prints the same warning; git clean -x would try to remove it and fail.)

To start over (every customer, token and memory gone) without sudo, let the one-shot data-init service, which runs as root and mounts only ./data, empty it:

docker compose down -v     # containers and the Qdrant volume (every memory)
docker compose run --rm --no-deps data-init find /app/data -mindepth 1 ! -name .gitkeep -delete
docker compose up -d       # fresh, empty stores

To delete the checkout instead, first give ./data back to your user, then remove the containers and the Qdrant volume; after that the directory deletes normally:

docker compose run --rm --no-deps data-init chown -R "$(id -u):$(id -g)" /app/data
docker compose down -v

(If you keep the checkout, the next docker compose up takes ./data back for uid 10001.)

Run the CLI in the same container as the server. users_file and history_db are relative paths (data/...) resolved against the working directory /app; a CLI run elsewhere reads and writes a different store.

Back up both stores, and treat the backups as customer data. Backups, volume copies and Qdrant snapshots keep data after you erase a customer, until they are rotated out. The live Qdrant volume can too, for a while (see Erasing and exporting a customer).

Erasing and exporting a customer

# The customer's current memories, as JSON.
# Treat the output as customer data: create it with a restrictive umask.
(umask 077; docker compose exec -T memsilo python scripts/manage_users.py export <user_id> > export.json)

# Irreversible. Without --yes it refuses and deletes nothing.
docker compose exec memsilo python scripts/manage_users.py --json erase <user_id> --yes --revoke

-T tells Compose not to attach a pseudo-terminal. With a terminal attached, the command's error output shares one stream with its standard output, so a message could land inside export.json; without one, the file gets exactly the JSON and messages stay on your screen. (The Compose version we tested also dropped the terminal by itself when output was redirected; -T makes that explicit, and is what scripts and cron jobs need.)

Both commands check the id first and refuse anything that is not a well-formed user id (letters, digits, _ and -), before touching any store. That matters: Mem0's Qdrant store treats a user id of * as "match any user", so an unchecked erase '*' would erase every customer.

erase revokes the token first (with --revoke), deletes the customer's memories from Qdrant, then purges Mem0's history rows for those memories and the cached conversation messages for that customer from history.db. It then checks both: it re-reads history.db and exits non-zero if any of those rows is still there, and it counts the customer's memories in Qdrant and exits non-zero if any remain.

These checks cover the memory ids that this run collected. Once the memories are gone from Qdrant, a later erase cannot find those ids again: it would report Erased 0 memories while their history rows stayed. So the two failure messages differ:

  • erase failed ... re-run ... (safe to repeat): deleting from Qdrant failed, but the history of every memory it did delete was purged and checked. What is left is still in Qdrant, and a re-run finds it.

  • erase incomplete ...: the history purge failed or left rows behind. The message lists the memory ids (the first 200; with --json, stdout carries all of them as memory_ids). Do not just re-run; purge them by hand as below.

Recovering from an incomplete erase

Fix what made the purge fail first (the message says, for example a full disk or a locked database), then delete the listed ids' history rows and the customer's cached messages. The image has no sqlite3 command, so use Python, inside the container, as the container user, with the user id and then every memory id from the message:

docker compose exec -T memsilo python - <user_id> <memory_id> <memory_id> ... <<'EOF'
import json, sqlite3, sys
user_id, ids = sys.argv[1], json.dumps(sys.argv[2:])
con = sqlite3.connect("data/history.db")
con.execute("PRAGMA secure_delete=ON")
with con:
    h = con.execute("DELETE FROM history WHERE memory_id IN (SELECT value FROM json_each(?))", (ids,)).rowcount
    m = con.execute("DELETE FROM messages WHERE session_scope = ?", ("user_id=" + user_id,)).rowcount
left = con.execute("SELECT count(*) FROM history WHERE memory_id IN (SELECT value FROM json_each(?))", (ids,)).fetchone()[0]
print(f"deleted {h} history rows and {m} cached message rows; {left} rows remain")
EOF

It must end with 0 rows remain. The same limits as erase apply (backups, snapshots and the users.json row keep their data; see below).

Memories past a Mem0 expiration_date are hidden from search and listing but are still stored: erase deletes them and export includes them.

export returns only the customer's current memories from the vector store (id, memory, created_at, metadata; at most 100,000 memories per customer, the same cap memory_stats reads). It does not include Mem0's history rows (earlier versions of memory text), the cached conversation messages, or the customer's row in users.json. It is not a complete data-subject export. When it finds no memories for an id that is also not in the user store, it still prints [] but warns on standard error, since that is usually a mistyped id.

Things to know, stated plainly:

  • erase and export build the same Mem0 client as the server, so with the anthropic provider ANTHROPIC_API_KEY must be set (an unset key is a one-line error). They never call the LLM provider, so a placeholder value works for them. add, bulk-add, rotate, revoke and list do not need it at all.

  • Known limit: delete_memory purges the history rows of the memory it deletes, so those are gone. What remains unreachable are history rows of memories that Mem0 itself deleted or merged before the erase: they cannot be traced back to a customer (the history table has no user id), so their text can remain in history.db. A follow-up is to prune history rows whose memory no longer exists.

  • Known limit: Qdrant keeps deleted text on disk for a while. erase and delete_memory remove the points through Qdrant's API, and they are gone from every search and listing. But Qdrant deletes are soft: after an erase we still found the erased text in the Qdrant volume, in its write-ahead log (wal/) and in the segment's payload file, and a Qdrant restart did not remove it. Qdrant drops those bytes only when its WAL rotates and the segment is rebuilt by its vacuum optimizer, which in Qdrant 1.19.0 does not run for collections under 100 points (vacuum_min_vector_number cannot be set lower). memsilo has no verified way to force it. Until then, treat the Qdrant volume as still containing erased customers' text: encrypt the disk or volume at rest, protect and rotate backups of it accordingly, and if you must remove the bytes, rebuild the collection (copy the live points into a new collection and delete the old one) with Qdrant's own tools. We have not tested a rebuild procedure, so we do not give one.

  • Backups and Qdrant snapshots retain data until you rotate them out.

  • users.json keeps a row after erase --revoke. Revoking only sets revoked: true; user_id, display_name, created_at and the token hash stay in the store. That is why --display-name should be a non-identifying label (an internal customer number), never a real name or phone number. Removing the row means editing the file or starting a fresh store, which memsilo has no command for.

  • If you install mem0ai's optional NLP extra (spaCy), Mem0 additionally keeps a <collection>_entities collection of entity names extracted from memory text, which erase does not clean. The provided image does not include spaCy, so with it that collection is not populated (Mem0 2.0.20 extracts entities only through spaCy).

  • Text already sent to the LLM provider is governed by that provider's retention terms, not by memsilo.

Development

poetry install
poetry run pytest tests/ -q
poetry run ruff check . && poetry run ruff format --check .

just ci runs the same checks; just run starts the server on the stdio transport. Over stdio there is no authentication: it is a local process launch acting as MEMSILO_DEFAULT_USER_ID. Never use it for customers. The documentation site (mkdocs.yml, docs/) is built with MkDocs.

Licence

MIT, see LICENSE.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      Zero-knowledge, self-hostable MCP server providing encrypted durable memory for AI agents, with tools to save, recall, search, list, and delete memories.
      472 npm
      69
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      A vendor-independent memory layer MCP server that enables multiple LLMs and AI agents to share and persist personal memories, knowledge, and context across sessions, with support for full-text search and tagging.
      ISC
    • F
      license
      Not graded
      quality
      D
      maintenance
      An MCP server for mem0 memory management backed by Qdrant, with per-collection isolation via HTTP headers. Enables storing, searching, updating, and deleting memories through natural language.
      -