Skip to content

Lucerna-Systems/Storify

Repository files navigation

memory-mcp

A persistent memory and a searchable map of your codebase — for Claude Code, Codex CLI, or any MCP client.

Coding agents forget everything between sessions and re-read whole files to answer questions a grep-and-hope search could've answered in one shot. memory-mcp fixes both: a branch-scoped note store your agents can read and write to like a shared brain, and a tree-sitter-indexed map of your codebase that returns exact file/line hits instead of forcing an agent to pull entire files into context just to find one function.

Self-hosted. Free. Runs entirely on your machine — Qdrant, Memgraph, and Ollama in Docker, no API keys, no per-token embedding cost, no data leaving your machine.


Contents


Why

Two problems, one server:

Agents don't remember. Close the session and every hard-won insight — why a piece of code is shaped the way it is, what a past bug's actual root cause was, a decision that took twenty minutes to reach — is gone. The next session (or the other agent you're also using) starts from zero and burns tokens re-deriving what was already figured out.

Agents read too much to find too little. Ask "where's the retry logic" and a typical agent greps, guesses, and opens three whole files to confirm one function. That's tokens spent on file contents you never needed.

memory-mcp is two systems behind one MCP server: a branch-scoped note graph (so Claude Code and Codex CLI can hand facts back and forth without polluting main with scratch notes), and a semantic codebase index built on real syntax trees, not regex — so search_code hands back exact file, symbol, and line range instead of a pile of files to read manually.

Features

  • Persistent, branch-scoped memory — notes are tagged repo + branch, so work on feature/x doesn't leak into or get diluted by other branches, while main stays the durable, promoted-on-purpose source of truth.
  • Shared across clients — Claude Code and Codex CLI (or any MCP client) read and write the same store. Hand off mid-task without re-explaining anything.
  • Real code understanding, not regexindex_codebase chunks along actual tree-sitter syntax boundaries (functions, classes, methods) across 12+ languages, not brace-counting heuristics.
  • Built for token efficiencysearch_code returns compact hits (file, symbol, line range, score) by default, not full file text. Read only what you need, when you know you need it.
  • Graph-aware — every note and every code symbol lives in a Memgraph graph, so "what's connected to this" is one get_entity_graph call instead of manual cross-referencing.
  • Self-hosted and free — Qdrant + Memgraph + Ollama, all local Docker containers. No API keys, no per-token embedding bill, nothing leaves your machine.
  • Two transports — stdio (default, zero config) or Streamable HTTP, for clients that can't spawn local processes or need to reach the server remotely.
  • Safe by default — writes are explicit and branch-scoped, main is never auto-written or auto-pruned, and destructive operations (prune_scope) require confirmation and respect a staleness gate.

Quick start

# 1. Set where your code lives (covers this once, for every repo underneath it)
echo "CODE_WORKSPACE=/Users/you" > .env

# 2. Bring up the stack
docker compose up -d

# 3. Pull the embedding model
docker exec -it $(docker compose ps -q ollama) ollama pull nomic-embed-text

# 4. Confirm everything's healthy
docker compose logs -f memory-mcp
# [memory-mcp] health check — qdrant: ok, memgraph: ok, ollama: ok

Qdrant's memory_chunks and code_chunks collections (768-dim, Cosine) are created automatically on boot. If you ever need to create them by hand:

curl -X PUT http://localhost:6333/collections/memory_chunks \
  -H "Content-Type: application/json" \
  -d '{"vectors": {"size": 768, "distance": "Cosine"}}'

Then wire it into your MCP client of choice — see Wiring up a client below, or jump straight to setup_guide.md for the full walkthrough (Claude Code, Codex CLI, Claude Desktop, and generic HTTP clients).

How it works

 ┌───────────────┐        ┌───────────────┐
 │  Claude Code  │        │   Codex CLI   │        ...any MCP client
 └───────┬───────┘        └───────┬───────┘
         │                        │
         └──────────┬─────────────┘
                     │  MCP (stdio or HTTP)
                     ▼
            ┌────────────────┐
            │   memory-mcp    │   8 tools, one server
            └────────┬────────┘
       ┌──────────────┼──────────────┐
       ▼              ▼              ▼
 ┌───────────┐  ┌────────────┐  ┌──────────┐
 │  Qdrant   │  │  Memgraph  │  │  Ollama  │
 │  vectors  │  │   graph    │  │ embeddings│
 └───────────┘  └────────────┘  └──────────┘

Every write goes to both stores: a vector (Qdrant, for semantic search) and a graph node (Memgraph, for relationships — RELATES_TO between notes, DEFINES/IMPORTS between code symbols and files). A search does a vector lookup first, then walks 1-2 hops in the graph from each hit to surface what's connected — so you get both "what's semantically similar" and "what's structurally related" in one call.

Tools reference

Tool What it does
search_memory Semantic + graph search over notes, scoped to repo + branch (plus main).
ingest_note Store a durable finding — embedded into Qdrant, added as a graph node.
get_entity_graph Walk everything connected to a note or code symbol, up to N hops.
promote_to_main Copy a branch-scoped note into main at merge time (never auto, never bulk).
list_stale_scopes List branch scopes with no activity in STALE_BRANCH_DAYS+ days.
prune_scope Delete a stale branch scope's notes. Refuses to run on active branches or main without an explicit override.
index_codebase Tree-sitter-chunk a codebase into Qdrant vectors + a File/Symbol graph. Incremental by default.
search_code Semantic code search — compact hits (file, symbol, line range, score), not whole files.

Want your agents to actually use these efficiently instead of falling back on Read/Grep out of habit? Copy CLAUDE.md.example or AGENTS.md.example into the repo you've indexed — they're a ready-made set of usage rules for exactly that.

Branch-scoped memory (Claude Code ↔ Codex CLI)

Every note carries repo, branch, source (claude/codex), and an optional session_id. search_memory / get_entity_graph filter to a repo + branch (plus main, unless include_main: false), so work on feature/x doesn't see — or get diluted by — notes from other branches, while still surfacing durable main-scoped knowledge.

Pass branch explicitly — this is the recommended path, not a fallback. Claude Code and Codex CLI both run on your host, sitting in the repo's real working directory, so getting the true current branch is one local command away:

git branch --show-current

Pass that result as branch on every search_memory / get_entity_graph / ingest_note call. This has no dependency on how memory-mcp's container is mounted, so moving/renaming folders on your host can't break it.

branch is technically optional on search_memory and get_entity_graph (always required on ingest_note) — if omitted, memory-mcp falls back to running that same git branch --show-current inside its own container, against WORKSPACE_ROOT/<repo> (mirrors the CODE_WORKSPACE mount index_codebase uses). Treat this as a convenience fallback, not something to rely on: the container can't see your host's filesystem beyond that mount, so if repo isn't a folder directly under it, or is checked out to a different branch than what you actually have open, this doesn't error — it silently returns the wrong branch, and your call would read or write the wrong scope. It only errors when detection is unambiguously impossible (detached HEAD, nothing mounted at that path, name mismatch).

Lifecycle:

Event What happens
Branch created Nothing explicit — the first ingest_note on that branch name creates its scope.
During work Both clients call search_memory / get_entity_graph scoped to the current branch before falling back to file search, and ingest_note durable findings as they go.
Branch merged Review the branch's notes and call promote_to_main on the handful worth keeping (architecture decisions, root causes) — one at a time, never bulk. Scratch/debug notes are left behind.
Branch gone stale Call list_stale_scopes to see branches with no ingest_note activity in 14+ days (STALE_BRANCH_DAYS), then prune_scope to delete them. Deliberately not keyed off git branch deletion — branches often survive locally after merge, so staleness (not git state) is the signal.
// ingest_note — write a scratch finding while working feature/replication-fix
// (branch is `git branch --show-current` run in your own shell)
{ "text": "Retry logic in sync.ts assumes idempotent writes — it isn't for the Kafka path", "branch": "feature/replication-fix", "repo": "luminary", "source": "claude" }

// search_memory — read from the same branch (from either client)
{ "query": "why does replication retry sometimes duplicate", "branch": "feature/replication-fix", "repo": "luminary" }
// include_main defaults true, so main-scoped notes are included too

// promote_to_main — at merge time, keep the root-cause note, leave the rest
{ "note_id": "<id from ingest_note>", "repo": "luminary" }

// list_stale_scopes / prune_scope — periodic cleanup
{ "repo": "luminary" }
{ "branch": "feature/old-experiment", "repo": "luminary", "confirm": true }

prune_scope refuses to run on a branch active within the last STALE_BRANCH_DAYS days unless you pass force: true, and can never touch main. promote_to_main copies, it never moves — the original branch-scoped note survives until its branch scope is eventually pruned.

Known gaps:

  • Branch rename/rebase isn't handled — scope is keyed by branch name string, so a rename effectively starts a new scope. Rare enough to fix manually if it comes up.
  • No auto-promotion, ever — promote_to_main is always an explicit, one-fact-at-a-time call.
  • Verify both clients see the tools before relying on this for hand-off: /mcp in Codex, the equivalent check in Claude Code.

Codebase indexing

index_codebase needs filesystem access to the code from inside the container. docker-compose.yml mounts CODE_WORKSPACE read-only at the identical path inside the container as on your host — no path translation. Set it once, in a root-level .env next to docker-compose.yml (see .env.example), to a folder that covers everywhere you keep code. Repos on your Desktop, in Documents, in a "Projects" folder, etc. are all nested under your home directory anyway, so pointing this at your home dir covers all of them with one mount:

# .env, next to docker-compose.yml
CODE_WORKSPACE=/Users/you

Then, from an MCP client, rootPath is just the real path, no translation needed:

// if CODE_WORKSPACE=/Users/you, and you want to index /Users/you/Desktop/my-app
{ "rootPath": "/Users/you/Desktop/my-app" }

You never have to reconfigure the mount or restart the stack when you start working on a different repo, as long as it's somewhere under CODE_WORKSPACE — just pass whatever absolute path you're actually sitting in. Anything outside that folder isn't visible to the container at all (mount is read-only, scoped to that one folder).

Code truly outside your home directory (an external drive, /opt, another volume) needs its own mount — a single mount can't span two unrelated filesystem roots:

    volumes:
      - ${CODE_WORKSPACE}:${CODE_WORKSPACE}:ro
      - ${CODE_WORKSPACE_2}:${CODE_WORKSPACE_2}:ro   # e.g. /Volumes/external

index_codebase walks the tree (respecting the root .gitignore plus common junk dirs — node_modules, dist, .git, __pycache__, etc.), splits each file into chunks along real syntax boundaries via tree-sitter (functions, classes, methods — each method gets its own chunk, not folded into its class — for JS/TS, Python, Go, Rust, Java, C#, Kotlin, Scala, C, C++, PHP, and Ruby; fixed-window chunks as a fallback for anything else), embeds each chunk, and writes:

  • vectors + metadata into the code_chunks Qdrant collection
  • a (:File)-[:DEFINES]->(:Symbol) graph in Memgraph, plus best-effort (:File)-[:IMPORTS]->(:File) edges for JS/TS and Python relative imports

It's incremental by default: re-running it skips files whose content hash hasn't changed, and prunes graph/Qdrant entries for files deleted from disk. Pass "incremental": false to force a full rebuild — needed the first time you fix a bug in the indexing pipeline itself, since incremental mode will otherwise skip files it already (incorrectly) processed.

Performance. Each file's writes are batched to a handful of round-trips regardless of chunk count: one Ollama /api/embed call per file (its chunks embedded together, not one HTTP request per chunk), one Qdrant upsert for all of the file's chunks, and one Memgraph UNWIND call for all of its symbols — instead of one network round-trip per chunk/ symbol, which is what made larger codebases slow to begin with. Files themselves are also processed concurrently (INDEX_FILE_CONCURRENCY, default 6). Tune these if indexing is still slow or if it's overwhelming your Qdrant/Memgraph/Ollama containers:

Env var Default What it controls
INDEX_FILE_CONCURRENCY 6 Files processed at once. The main lever for overall indexing speed.
EMBED_BATCH_SIZE 64 Chunks per /api/embed request. Lower if Ollama is memory-constrained.
EMBEDDING_CONCURRENCY 4 /api/embed requests in flight at once (across a file's own sub-batches, and across concurrent files).

Then search it:

{ "query": "how do we retry failed webhook deliveries", "repo": "my-app" }

search_code returns compact hits by default — file path, symbol name, kind, line range, score — without the chunk text, so the calling agent reads only the lines it actually needs instead of pulling whole files into context. Pass "includeText": true to get the chunk body inline when you already know you want it. Results also include relatedFiles, pulled from the IMPORTS graph edges of the top hits.

Chunking is tree-sitter-based, not heuristic. Chunk boundaries come from the real syntax tree — exact start/end of each function, class, and method — rather than regex/brace counting. A class produces a small "header" chunk (signature + any fields declared before the first method) plus one chunk per method, instead of one blob covering the whole class. Files whose language has no bundled grammar (or that fail to parse) fall back to fixed line-count windows with overlap, so nothing is silently dropped.

Known limitations:

  • Version pinning is load-bearing. web-tree-sitter is pinned to 0.24.x in package.json because tree-sitter-wasms' prebuilt .wasm grammars are compiled against an older tree-sitter ABI — newer web-tree-sitter runtimes (0.25+) refuse to load them at all ("dylink metadata" error). If you ever bump either dependency, bump both together and re-verify with a throwaway parse before deploying — see the comment at the top of src/codebase/treeSitter.ts.
  • Image size. The bundled grammars add roughly 50MB to node_modules (all ~35 languages tree-sitter-wasms ships, even though only ~14 are wired up in language.ts) — no native compilation needed, since it's pure WASM, but it's a real size cost in the Docker image.
  • Very large single symbols (e.g. a 1000-line function) are still capped at 400 lines; the overflow is swept up as a separate chunk rather than dropped.
  • A few grammars don't expose field names for identifiers the way most do (Kotlin) or bury the name in a declarator chain (C/C++) — those get language-specific extraction logic in language.ts rather than a generic childForFieldName("name") lookup. Occasionally a chunk may come back unnamed if a construct doesn't match the expected shape.
  • No grammar is wired up for Go/Rust/Kotlin/Scala/etc. import statements (import-edge resolution stays regex-based) — only handles relative imports (./foo, ../bar, Python's from .pkg import x) for JS/TS and Python. Other languages get indexed and are searchable, just without IMPORTS edges yet.

Wiring up a client

Claude Code and Codex CLI each register memory-mcp as their own MCP server directly (no proxying) and both read/write the same Qdrant + Memgraph instance.

Claude Code.mcp.json, or claude mcp add:

{
  "mcpServers": {
    "memory": {
      "command": "docker",
      "args": ["exec", "-i", "memory-mcp", "node", "dist/main.js"]
    }
  }
}

Codex CLI~/.codex/config.toml, or codex mcp add memory-mcp -- docker exec -i memory-mcp node dist/main.js:

[mcp_servers.memory-mcp]
command = "docker"
args = ["exec", "-i", "memory-mcp", "node", "dist/main.js"]

Both use stdio by default (zero config). An HTTP transport (MCP_TRANSPORT=http or both) is also available for clients that can't spawn local processes, or for reaching the server from another machine — Claude Desktop needs a small bridge for it, stdio works there directly.

For the full walkthrough — every client (Claude Code, Codex CLI, Claude Desktop, generic HTTP clients), both transports, and the gotchas that come with each — see setup_guide.md. Verify both clients see all eight tools before relying on this for hand-off between them (/mcp in Codex, the equivalent check in Claude Code).

Local development (without Docker)

cd memory-mcp
cp .env.example .env   # point at localhost instead of the compose service names
npm install
npm run dev             # runs src/main.ts directly via tsx

For local dev, edit .env to use http://localhost:6333, bolt://localhost:7687, and http://localhost:11434 instead of the Docker Compose service hostnames, and run qdrant, memgraph, and ollama via docker compose up -d qdrant memgraph ollama (skip building memory-mcp itself). rootPath for index_codebase can then be any path on your machine, and WORKSPACE_ROOT for branch auto-detection can point anywhere git is on your PATH can reach — no mount needed at all.

Project layout

docker-compose.yml
setup_guide.md                       # consolidated client setup (all clients, both transports)
CLAUDE.md.example / AGENTS.md.example # usage rules to copy into an indexed repo
memory-mcp/
  Dockerfile
  .env / .env.example
  package.json / tsconfig.json
  mcp-client-config.example.json      # Claude Code / Claude Desktop, stdio
  mcp-client-config-http.example.json # Claude Code, HTTP
  codex-config.example.toml           # Codex CLI, stdio + HTTP
  src/
    config.ts             # env loading (incl. MCP_TRANSPORT/MCP_HTTP_PORT/MCP_HTTP_PATH)
    types.ts               # shared TS types for chunks/nodes/edges/scopes
    embeddings.ts           # Ollama embedding client
    qdrant.ts               # Qdrant client + memory_chunks collection + scope filters
    graph.ts                # Memgraph (Bolt/Cypher) client via neo4j-driver + indexes
    scope.ts                # (:Scope) node tracking for staleness (list_stale_scopes/prune_scope)
    git.ts                  # branch auto-detection via `git -C <repo> branch --show-current`
    healthcheck.ts           # startup connectivity check
    httpServer.ts             # Streamable HTTP transport (stateless, fresh server/request)
    util/
      concurrency.ts         # bounded-concurrency map helper
    codebase/
      walk.ts                 # gitignore-aware file discovery
      language.ts              # extension -> language, tree-sitter node-type/kind config
      treeSitter.ts             # web-tree-sitter runtime init + per-language grammar loading
      chunker.ts                 # AST-based chunking (tree-sitter) + fixed-window fallback
      imports.ts                  # relative-import resolution (JS/TS, Python) — regex, unchanged
      hash.ts                      # content hashing for incremental reindex
      qdrantCode.ts                 # code_chunks collection + search/upsert/delete
      graphCode.ts                   # File/Symbol graph nodes + IMPORTS/DEFINES
    tools/
      searchMemory.ts
      getEntityGraph.ts
      ingestNote.ts
      promoteToMain.ts
      listStaleScopes.ts
      pruneScope.ts
      indexCodebase.ts
      searchCode.ts
    main.ts                # MCP server entrypoint (stdio and/or HTTP, per MCP_TRANSPORT)

Roadmap

Not built yet, deliberately deferred rather than half-done:

  • Telegram bot listener
  • GitHub webhook sync (issues, PRs, commits)
  • Auto entity extraction / cross-source auto-linking for notes
  • Fully-typed Person/Issue/PR graph population (today's graph is mostly generic Note nodes from ingest_note, plus the File/Symbol nodes from index_codebase)
  • Cross-repo memory — repo is required on every call today, but nothing yet aggregates or compares across repos
  • Automatic branch-delete hook to trigger prune_scope — staleness-based cleanup covers this manually/periodically for now

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors