Claude Code ships with a built-in memory system, but it’s markdown files scoped to a project directory. I wanted one durable memory that follows me across every project and session, stays entirely on my machine, and doesn’t depend on a hosted memory service. Here’s the stack that does it — and the potholes hit along the way.
The shape of it
┌─────────────┐ SSE over HTTP ┌───────────────┐ SQL ┌──────────────┐
│ Claude Code │ ────────────────▶ │ mem0-open-mcp │ ──────▶ │ pgvector │
│ (MCP client │ :8765 + bearer │ (Docker) │ │ (Docker) │
│ + CLAUDE.md)│ │ mem0ai lib │ │ mem0_claude │
└─────────────┘ └──────┬────────┘ └──────────────┘
│ OpenAI-compatible API
▼ host.docker.internal:9800/v1
┌───────────────┐
│ oMLX (macOS) │
│ chat + embed │
└───────────────┘
Three moving parts: a model server, a database, and the MCP server. Each is boring on its own.
1. oMLX serves the models
mem0 makes two kinds of model calls: an LLM decides what’s worth remembering (extracting facts from conversation), and an embedder vectorizes memories for retrieval. Both run locally through oMLX, an OpenAI-compatible inference server for Apple Silicon:
- Chat:
gemma-4-e2b-it-4bit - Embeddings:
bge-m3-mlx-8bit— 1024 dimensions, remember this number
Because it speaks the standard /v1 API, mem0’s openai provider works against it unchanged. Docker containers reach it at http://host.docker.internal:9800/v1 — Docker Desktop’s alias for the host machine.
2. pgvector stores the memories
A local clone of the mem0 repo provides a compose project (server/docker-compose.yaml) whose postgres service is pgvector/pgvector:pg17. The full mem0 API server and dashboard from that compose file aren’t needed — the MCP server talks to Postgres directly through the mem0ai Python library — so only the database container runs.
An init script mounted into /docker-entrypoint-initdb.d/ creates a dedicated database and user on first boot, so Claude’s memories don’t share a schema with anything else using the same Postgres:
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<'EOSQL'
CREATE USER mem0_claude_user WITH PASSWORD '<db-password>' SUPERUSER;
EOSQL
createdb -U "$POSTGRES_USER" mem0_claude -O mem0_claude_user || true
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d mem0_claude <<'EOSQL'
CREATE EXTENSION IF NOT EXISTS vector;
EOSQL
Scripts in that directory run in alphabetical order — the zz- prefix makes this one run after the repo’s own init script.
3. mem0-open-mcp is the bridge
mem0-open-mcp is the open-source MCP server that exposes mem0 as tools: add_memories, search_memory, list_memories, and friends. It runs as a small custom image:
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "mem0ai<2" "mem0-open-mcp" "mcp>=1.28,<2" "psycopg[binary,pool]"
# Patch a real bug in 0.2.12: AsyncMemory.from_config is not a coroutine,
# so awaiting it crashes the server during startup.
RUN sed -i 's/await AsyncMemory\.from_config/AsyncMemory.from_config/g' \
/usr/local/lib/python3.11/site-packages/mem0_server/server.py \
/usr/local/lib/python3.11/site-packages/mem0_server/cli.py
EXPOSE 8765
CMD ["mem0-open-mcp", "serve"]
All behavior lives in a bind-mounted YAML, so config edits never require a rebuild:
server:
host: "0.0.0.0"
port: 8765
user_id: "claude_user_01"
auth:
api_key: "<bearer-token-clients-must-send>"
llm:
provider: "openai"
config:
base_url: "http://host.docker.internal:9800/v1"
model: "gemma-4-e2b-it-4bit"
api_key: "env:OPENAI_API_KEY"
embedder:
provider: "openai"
config:
base_url: "http://host.docker.internal:9800/v1"
model: "bge-m3-mlx-8bit"
api_key: "env:OPENAI_API_KEY"
embedding_dims: 1024
vector_store:
provider: "pgvector"
config:
host: "postgres" # compose service name, resolved on the docker network
port: 5432
collection_name: "claude_memories"
embedding_model_dims: 1024
extra:
dbname: "mem0_claude"
user: "mem0_claude_user"
password: "<db-password>"
A compose override in the mem0 clone adds the service to the same network as Postgres:
services:
mem0-open-mcp:
build:
context: ~/.config/mem0-claude
dockerfile: Dockerfile
image: mem0-open-mcp:local
restart: unless-stopped
volumes:
- ~/.config/mem0-claude/mem0-open-mcp.yaml:/app/mem0-open-mcp.yaml:ro
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
networks:
- mem0_network
ports:
- "8765:8765"
command: mem0-open-mcp serve --port 8765 --user-id claude_user_01
depends_on:
- postgres
4. Teaching Claude Code to use it
Register the server at user scope so every project sees it:
claude mcp add --scope user --transport sse mem0 \
http://127.0.0.1:8765/mcp/claude/sse/claude_user_01 \
--header "Authorization: Bearer <bearer-token>"
The URL path encodes the client name and user id; the bearer token must match auth.api_key in the YAML.
Tools existing isn’t enough — Claude also needs to know when to call them. My global ~/.claude/CLAUDE.md says, in effect: mem0 is the primary memory store — save durable preferences and context there, search it when past sessions would help, and fall back to the file-based memory only when the server is unreachable. Without that paragraph, the server just sits there unused.
Gotchas
- The
awaitbug. mem0-open-mcp 0.2.12 awaitsAsyncMemory.from_config, which isn’t a coroutine — the server crash-loops on startup. Thesedpatch in the Dockerfile is the fix until a release lands. - Boot order matters. The server builds its async connection pool at startup. If Postgres isn’t accepting connections yet, initialization fails permanently and every tool call errors until the container is restarted.
depends_onalone doesn’t wait for readiness. Recovery is: start the database, thendocker restart mem0-open-mcp. (This post exists because today’s session began in exactly that broken state.) embedding_dimsmust match the model. bge-m3 emits 1024-dim vectors, and the pgvector collection is created with whatever dims the config claims on first write. A mismatch means confusing errors or a silently wrong table. Set it in both theembedderandvector_storesections.- Namespace per assistant.
user_idandcollection_name(claude_user_01/claude_memories) keep Claude’s memories separate from other agents sharing the same stack. Memory is only useful when it’s scoped.
Checking it works
claude mcp list # mem0 should show ✔ Connected
docker ps | grep -E 'mem0|postgres' # both containers up
Then the real test: tell Claude “remember that I prefer X”, start a new session, and ask what it knows about you. If the answer comes back, the loop is closed.