memser
memser — Memory-as-a-Service MCP Server
An MCP server that exposes memory operations (store, recall, forget,
consolidate, list) as tools, backed by a tiered storage engine, so any
MCP-compatible agent (Claude Desktop, Claude Code, a Telegram bot, custom
agents) can plug in for persistent memory.
Layers
MCP Interface Layer (app/server.py) store_memory, recall_memory,
forget_memory, list_memories,
consolidate_memories
Memory Engine (app/memory_engine.py, scoring, dedup, conflict
app/consolidation.py, app/scoring.py) detection, decay scheduling
Storage Tiers
- Working (Redis, TTL) app/redis_client.py
- Episodic (Postgres, JSONB-ish) app/models.py (tier="episodic")
- Semantic (Postgres + pgvector) app/models.py (tier="semantic")
Background Jobs (APScheduler) app/jobs.pySetup
cp .env.example .env # adjust as needed
docker compose up -d # Postgres+pgvector and Redis
pip install -e ".[dev]" # or: pip install -r requirements.txt
# Only needed if not using docker-compose's auto-init:
python -m scripts.init_db
python -m app.server # runs the MCP server over stdioTo try it with the MCP inspector instead of a real client:
mcp dev app/server.pyTo wire it into Claude Desktop/Code, add to its MCP config:
{
"mcpServers": {
"memory-service": {
"command": "python",
"args": ["-m", "app.server"],
"cwd": "/path/to/memser",
"env": { "DATABASE_URL": "...", "REDIS_URL": "...", "OPENAI_API_KEY": "..." }
}
}
}Without OPENAI_API_KEY, embeddings fall back to a deterministic local hash
embedder (app/embeddings.py::HashEmbedder) — the server runs fully offline,
but recall quality is lexical-overlap-only, not real semantic search.
Consolidation summarization similarly falls back to naive dedup/join instead
of an LLM call.
Tools
Tool | Purpose |
| Write a memory. |
| Hybrid search: working tier + vector similarity over episodic/semantic, ranked by similarity + recency + frequency + confidence. |
| Cascading delete across Redis + Postgres, with an audit-log snapshot written before deletion. |
| Clusters episodic memories, LLM-summarizes each cluster into a semantic fact, marks originals |
| Audit/debug listing, no ranking. |
scope is the tenancy boundary (user id, agent id, or session id) — nothing
crosses scopes. Every tool call is written to memory_audit_log (action,
actor, memory id, scope, detail).
Data model
See db/init.sql for the authoritative schema (memories,
memory_audit_log) and app/models.py for the SQLAlchemy
mapping. Key points:
memories.embeddingisVECTOR(1536)(pgvector), indexed with anivfflatcosine-distance index.superseded_bychains episodic → semantic (via consolidation) and old-fact → new-fact (via conflict resolution) without ever deleting the superseded row — that's the audit trail.forget_memoryis the only thing that actually deletes rows, and it logs a full content snapshot tomemory_audit_logfirst.
Key mechanisms
Conflict detection (app/memory_engine.py): before a durable store, the new embedding is compared against the scope's active memories. Above
DUPLICATE_THRESHOLDcosine similarity, the write is treated as a duplicate (bumpsaccess_count/confidenceon the existing row instead of inserting). AboveCONFLICT_THRESHOLDbut below duplicate, the new memory is inserted and the older, similar ones are markedsuperseded_bythe new row.Consolidation (app/consolidation.py): greedy single-linkage clustering of a scope's episodic memories by cosine similarity, one LLM summarization call per cluster, one new semantic row per cluster, originals marked superseded (not deleted).
Scoring/decay (app/scoring.py): recall ranks by
similarity, recency (exponential half-life), frequency (diminishing returns on access_count), confidence, weighted per.env. A background decay job (app/jobs.py::run_decay_sweep) shrinks confidence on stale, unaccessed episodic memories over time.Scoped forgetting:
forget_memoryalways requires ascope, and cascades to every tier a memory could live in (Redis working entry + Postgres row), logging who deleted what and when before the row is gone.
Background jobs
app/jobs.py runs two APScheduler interval jobs inside the same process
as the MCP server (started/stopped via the FastMCP lifespan hook in
app/server.py):
run_consolidation_sweep— everyCONSOLIDATION_INTERVAL_MINUTES, consolidates every scope with at leastCONSOLIDATION_MIN_EPISODICun-consolidated episodic memories.run_decay_sweep— everyDECAY_INTERVAL_MINUTES, decays confidence on episodic memories untouched forDECAY_AFTER_DAYS.
Set ENABLE_SCHEDULER=false to disable both (e.g. run consolidation only
via explicit consolidate_memories calls, or move this to Celery beat for
a multi-process deployment — the job bodies are plain async functions and
don't depend on APScheduler specifically).
Tests
pytestTests cover the pure logic (scoring math, hash-embedding determinism,
clustering, summarization fallback) and don't require Postgres/Redis to be
running. There's no integration test suite against a live database in this
scaffold — add one with a pgvector/pgvector test container if you extend
this.
Known scaffold limitations
cluster_by_similarityis O(n²) greedy single-linkage — fine for per-scope batches of tens–low-hundreds of episodic memories between consolidation runs; swap in a real clustering/ANN approach if scopes grow much larger before consolidating.Conflict resolution always lets the newest similar memory supersede the older one(s); there's no merge-vs-flag-for-human-review path — add one if blind "newest wins" isn't safe for your use case.
Single-process scheduler (APScheduler) — fine for one server instance; move to Celery beat (as sketched in the original architecture doc) if you run multiple replicas and need a single consolidation/decay owner.