By default, all LLM intelligence runs inside Claude Code sessions (covered by a Max subscription). The MCP server is a thin pipe to Neo4j — it executes Cypher, returns results, nothing more. Entity extraction, Cypher generation, and reasoning all happen in Claude Code where your Max plan covers it. An optional API-key runner can run the nightly dream instead; see DREAM_PROCESS.md.
LLM work (Max plan) Graph operations (local, free)
───────────────────── ──────────────────────────────
Claude Code sessions MCP Server → Neo4j
- Entity extraction - Execute Cypher queries
- Cypher generation - Return results
- Reasoning/synthesis - No LLM calls
- Dream process (scheduled) - No API key (default)
claude.ai web / Claude Desktop (remote) Claude Code / Claude Desktop (local)
│ HTTPS → https://your-host.example.com/mcp │ stdio MCP (docker exec)
│ OAuth 2.1 bearer token │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ graph-memory-mcp (Docker, port 3847) │
│ Node 22, node:http and node:https (no Express) │
│ │
│ User message → Claude reasons → Graph MCP query │
│ │ │
│ ▼ │
│ Relevant subgraph │
│ │ │
│ ▼ │
│ Read associated .md files │
│ │ │
│ ▼ │
│ Enriched context for response │
│ │
│ Memory write → .md file │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Markdown │ │ Transcript │ │ Ingest Drop Folder │
│ Memory Files │ │ Store │ │ (raw docs to │
│ (source of │ │ (JSONL in │ │ process) │
│ truth) │ │ ~/.claude/) │ │ │
└──────────────┘ └──────────────┘ └──────────────────────┘
│ │ │
└───────────────┼──────────────────────┘
│
┌───────────────────────────────────────┐
│ Dream Process (Scheduled Task) │
│ Runs AS a Claude Code session │
│ (Max plan; no API key by default) │
│ │
│ 1. Read new transcripts + ingest docs │
│ 2. Claude extracts entities/edges │
│ 3. Calls MCP tools to update graph │
│ 4. Decay + maintenance via MCP │
│ 5. Write changelog │
└───────────────────────────────────────┘
│
▼
┌──────────────────┐
│ graph-memory- │
│ neo4j (Docker) │
│ Neo4j 5.20 + │
│ APOC │
│ bolt://neo4j: │
│ 7687 (internal) │
└──────────────────┘
Why Neo4j Community:
- Full ACID-compliant graph database with proper concurrent access
- Readers don't block writers — MCP server and dream process connect simultaneously with zero issues
- Full Cypher query language support
- Official TypeScript driver (
neo4j-driver) — actively maintained, pure JS, no native bindings - Docker deployment on the primary device
- Mature ecosystem — GraphAcademy courses, Neogma OGM, active community
- Free for personal use (GPLv3)
Why not Kuzu: The kuzu npm package is deprecated. Native bindings add Windows compilation complexity. No concurrent access support.
Deployment:
# docker-compose.yml (repo root; abbreviated)
services:
neo4j:
image: neo4j:5.20-community # + APOC plugin
ports:
- "127.0.0.1:7474:7474" # Browser UI (optional, for debugging)
- "127.0.0.1:7687:7687" # Bolt (host-side access; MCP uses bolt://neo4j:7687 internally)
volumes: # bind mounts under GRAPH_MEMORY_HOME, not named volumes
- ${GRAPH_MEMORY_HOME}/neo4j/data:/data
- ${GRAPH_MEMORY_HOME}/neo4j/logs:/logs
- ${GRAPH_MEMORY_HOME}/neo4j/import:/import
graph-memory-mcp:
# Node 22; node:http/https server on port 3847 (no Express)
# Reached by Cloudflare Tunnel (remote) or docker exec (local stdio)
volumes:
- ${GRAPH_MEMORY_HOME}:/root/graph-memory
graph-dream:
# Optional API-key dream runner, started only with the "dream" profile
profiles: ["dream"]
command: ["node", "dist/dream-runner/index.js", "--daemon"]Resource footprint: ~512MB RAM (tunable). Graph data is single-digit MB for personal use. The JVM is the real cost.
Connection (MCP → Neo4j): bolt://neo4j:7687 over the compose internal network. The 127.0.0.1:7687 port binding is for local debugging via Neo4j Browser only.
Connecting to a managed Neo4j instead: the same code path also works against Neo4j Aura. Set NEO4J_URI=neo4j+s://<aura-instance> in .env, drop the neo4j service from docker-compose.yml, and the MCP container will talk to Aura directly. Local Docker is the default because it's faster, free, and keeps everything on one machine.
Browser UI: http://localhost:7474 — built-in query explorer for debugging and visual inspection. This partially addresses the reviewer's concern about no way to inspect the graph without Claude Code.
A local MCP server that executes Cypher queries against Neo4j. No LLM calls — it's a thin wrapper.
For remote access (claude.ai web, multi-device), the MCP server is exposed via Cloudflare Tunnel at https://your-host.example/mcp and implements OAuth 2.1 (RFC 8414, RFC 9728, RFC 7591, RFC 7009). See REMOTE.md for full setup details. The OAuth keypair is generated on first startup and persisted at ~/graph-memory/oauth/{private,public}.pem.
Exposed tools (~21 total):
Each tool is annotated with hints so Claude can reason about safety.
| Tool | Purpose | Needs LLM? | Hint |
|---|---|---|---|
graph_query |
Traverse from entities, return subgraph | No | readOnly |
graph_relate |
Create/strengthen a relationship | No | idempotent |
graph_boost |
Reinforce an edge based on confirmation | No | idempotent |
graph_weaken |
Reduce edge weight | No | — |
graph_entities |
List entities by type or search | No | readOnly |
graph_contradictions |
Find conflicting memories | No | readOnly |
graph_ingest |
Queue/status for document ingestion | No | idempotent |
graph_cypher |
Execute raw Cypher (read-only via executeRead()) |
No | readOnly |
graph_decay |
Apply decay to all stale edges/nodes | No | — |
graph_prune |
Preview/remove decayed nodes and edges | No | destructive |
graph_unmerge |
Split falsely merged entities | No | destructive |
graph_stats |
Graph health metrics | No | readOnly |
graph_delete |
Delete an entity and its edges | No | destructive |
graph_read_transcript |
Read and normalize a session JSONL transcript | No | readOnly |
graph_audit |
Append a structured event to the dream audit log | No | — |
graph_export |
Write a timestamped JSONL backup of all nodes+edges | No | — |
graph_validate |
Scan for quality issues (names, orphans, duplicates) | No | readOnly |
graph_communities |
Cluster entities via BFS through high-weight edges | No | readOnly |
graph_build_context |
Bundle graph health + context into one call | No | readOnly |
graph_search |
Hybrid semantic + graph traversal retrieval | No | readOnly |
graph_reembed |
Regenerate entity embeddings | No | idempotent |
graph_alias |
List, add or remove an entity's aliases | No | idempotent |
graph_merge |
Merge a duplicate entity into another (supports dry run) | No | destructive |
graph_merge_suggestions |
List likely duplicate entities | No | readOnly |
Key change from previous design: graph_cypher no longer translates natural language to Cypher. It only executes pre-formed Cypher queries (enforced read-only via Neo4j's executeRead() transaction mode). Claude in the session generates the Cypher itself — it knows the schema (injected via skill instructions) and can write Cypher directly. No Haiku call needed.
Registration (local stdio): Added to ~/.claude/settings.json under mcpServers for Claude Code / Claude Desktop running on the same machine:
This is the key architectural decision for the default setup. The dream process is NOT a standalone script that calls the Claude API. It IS a Claude Code scheduled task — meaning it runs as a full Claude Code session with access to your Max plan.
What this means:
- Claude itself reads the transcripts and extracts entities (no API key needed)
- Claude calls the MCP tools to write to the graph (same tools available as in any session)
- Claude writes the changelog
- All covered by your Max subscription
- Optional alternative:
dist/dream-runner/index.jsruns the same prompt through the Messages API on an API key. See DREAM_PROCESS.md.
Trigger conditions (lazy evaluation):
- Nightly scheduled task checks for new transcripts/ingest docs
- SessionStart hook does a fast filesystem check (no LLM, just file existence)
- If nothing new, the scheduled task prompt can detect this and exit early
Processing flow:
Scheduled task fires (Claude Code session)
→ Claude reads manifest.json to find unprocessed transcripts
→ Claude reads each JSONL transcript from ~/.claude/projects/
→ Claude extracts entities and relationships (its own reasoning, Max plan)
→ Claude calls graph_relate, graph_boost, etc. via MCP tools
→ Claude calls graph_decay via MCP tool
→ Claude writes changelog to ~/graph-memory/logs/
→ Claude updates manifest.json
Claude Code stores full conversation history locally:
| Location | Format | Content |
|---|---|---|
~/.claude/history.jsonl |
JSONL | All user inputs with timestamps and session IDs |
~/.claude/projects/<project>/<session-id>.jsonl |
JSONL | Full conversation transcripts |
~/.claude/sessions/ |
JSON | Session metadata (PID, cwd, start time) |
Each JSONL line includes: type, message, timestamp, sessionId, cwd, model.
The cwd field provides project context for affinity scoring.
// ~/.claude/settings.json (hooks section)
{
"hooks": {
// On session start: fast filesystem check for pending work
// NO LLM call — just checks file timestamps
"SessionStart": [
{
"command": "node <absolute-path-to-clone>/dist/hooks/check-pending.js",
"timeout": 3000
}
]
}
}Note: The reviewer flagged that PostToolUse hooks fire on ALL Write operations, not just memory files. We've removed that hook. The dream process handles all graph updates in batch — no real-time file indexing needed.
Scheduled tasks are stored at ~/.claude/scheduled-tasks/<task-name>/SKILL.md and configured via Claude Desktop's Schedule UI (or by asking Claude). The prompt content drives what the session does — see DREAM_PROCESS.md for the full nightly and weekly prompts.
Example tasks:
- graph-dream-nightly — processes new transcripts and ingest docs, runs decay, writes changelog. Runs nightly.
- graph-dream-weekly — deep health analysis: stats, contradictions, stale nodes, weekly summary.
- API-key runner (optional) — the same nightly prompt through
node dist/dream-runner/index.js. Add--job weeklyfor the weekly maintenance prompt, or--daemonto run both on a schedule. See DREAM_PROCESS.md.
The graph is useless if Claude never queries it. Three mechanisms ensure automatic retrieval:
A CLAUDE.md file in the graph-memory source repo (and optionally in ~/.claude/CLAUDE.md for global reach) instructs Claude to consult the graph during normal conversations:
# Graph Memory
You have access to a graph-based memory system via the `graph-memory` MCP server.
## When to consult the graph
- When the user asks about a **person**, **project**, **past decision**, or **preference** — call `graph_query` before answering from general knowledge
- When the user references something from a **previous conversation** — the graph likely has context
- When you're about to **write a memory file** — check `graph_entities` first to see if related knowledge already exists
- When the user starts a new session — consider offering `/graph-briefing` if the project has graph data
## How to use it
- `graph_query` for structured lookups (entities, relationships, weights)
- `graph_cypher` when you need a custom query (you know the schema — see /graph-ask skill)
- `graph_boost` when the user confirms something you recalled from the graph
- `graph_weaken` when the user corrects something the graph got wrong
## What NOT to do
- Don't query the graph for every message — only when the topic involves recallable knowledge
- Don't mention weights or graph internals to the user unless they ask
- Don't create entities during conversation — that's the dream process's jobEach MCP tool's description field is written to help Claude decide when to call it. For example, graph_query's description says: "Query the memory graph for entities related to a topic. Use this when the user asks about people, projects, decisions, or past context." Claude's tool-use reasoning picks up on these cues.
The /graph-briefing skill (and the SessionStart hook reminder) nudge Claude to load graph context at the start of sessions. This front-loads relevant knowledge so Claude doesn't need to query mid-conversation as often.
User: "How's the auth module going?"
→ Claude extracts entities: ["auth module"]
→ Calls graph_query(["auth module"], hops=2)
→ Graph returns: auth module → Project X (0.9),
auth module → OAuth2 (0.7),
Project X → You (1.0),
Project X → React (0.8)
→ Associated files: project_x.md, auth_decisions.md
→ Claude reads those files for full context
→ Response includes relevant history and context
Nightly task fires (Claude Code session)
→ Claude reads manifest, finds 3 unprocessed transcripts
→ For each: reads JSONL, extracts entities and relationships
→ Calls MCP tools to update graph:
- 5 new entities created via graph_relate
- 12 edges strengthened via graph_boost
- 2 new edges created via graph_relate
- 1 contradiction flagged
→ Calls graph_decay for maintenance
→ Writes changelog to ~/graph-memory/logs/2026-04-06.md
→ Updates manifest.json
User: /graph-ask Which people work on projects that use React?
→ Claude reads the graph schema (embedded in skill instructions)
→ Claude generates Cypher:
MATCH (p:Person)-[w:WORKS_ON]->(proj:Project)-[u:USES_TECH]->(c:Concept {name: 'react'})
WHERE w.weight > 0.3 AND u.weight > 0.3
RETURN p.name, proj.name, w.weight ORDER BY w.weight DESC
→ Calls graph_cypher(cypher=<above>) via MCP
→ Formats and presents results
Claude: "You mentioned preferring tabs for indentation"
User: "Yeah, always tabs"
→ Claude calls graph_boost("user", "tabs", "PREFERS", reason="explicit confirmation")
→ Edge weight: 0.7 → 0.85
User starts a new session in the graph-memory project
→ User runs /graph-briefing (or Claude proactively offers)
→ Claude calls graph_stats (overall health)
→ Claude calls graph_query(project_context="graph-memory", context_level="minimal")
→ Claude calls graph_contradictions (unresolved conflicts)
→ Presents structured briefing:
- Last dream run: 10 hours ago, processed 3 transcripts
- 2 new entities since last session
- 1 unresolved contradiction flagged
- Top entities for current project: Neo4j, MCP server, dream process
- No pending ingest documents
Dream process extracts 8 entities and 15 relationships from a transcript
→ Instead of 23 individual tool calls, makes 1 batch call:
graph_relate({
batch: {
entities: [8 entities with localIds],
relations: [15 relationships referencing localIds],
source_session: "abc-123",
source_transcript: "path/to/abc-123.jsonl",
source_type: "conversation"
}
})
→ All created atomically in one Neo4j transaction
→ Provenance and valid_at set on every edge
~/graph-memory/
├── backups/ # graph_export JSONL backups
├── ingest/ # Drop folder for documents to ingest
│ ├── pending/ # Drop files here — dream process picks them up
│ └── completed/ # Processed files moved here with metadata
├── logs/ # Dream process changelogs and run logs
│ ├── 2026-04-06.md
│ ├── weekly/ # Claude Code weekly analysis reports
│ ├── weekly-reports/ # API-key weekly reports (prompts/weekly-maintenance.md)
│ ├── dream-runner/ # API-key runner logs and runs.jsonl
│ └── ...
├── processed/ # Tracking file for processed transcripts
│ └── manifest.json
├── schema/ # Schema definition files (for backup/migration)
│ ├── v1.cypher # Initial schema
│ └── migrations/ # Schema migration scripts
└── config.json # Graph memory configuration (decay rates, weights, etc.)
~/.claude/projects/ # EXISTING — conversation transcripts (JSONL)
├── <project-path>/
│ ├── <session-id>.jsonl # Full conversation transcript
│ └── memory/ # Markdown memory files
<repo-root>/ # Source code repository (clone of this project)
├── docker-compose.yml # Neo4j + MCP container definitions
├── docs/
│ ├── ARCHITECTURE.md # This file
│ ├── GRAPH_SCHEMA_REFERENCE.md
│ ├── MCP_SERVER.md
│ ├── DREAM_PROCESS.md
│ ├── REMOTE.md # Cloudflare Tunnel + OAuth setup
│ ├── MULTI_TENANT.md
│ └── SKILLS.md
├── CLAUDE.md # Retrieval trigger instructions (also install globally)
├── src/
│ ├── mcp-server/ # MCP server source (thin Neo4j wrapper)
│ ├── hooks/ # Hook scripts (filesystem checks only)
│ └── shared/ # Shared utilities (Neo4j client, config loader)
├── package.json
└── tsconfig.json
| Component | Technology | Rationale |
|---|---|---|
| Language | TypeScript/Node.js | Matches the Claude Code ecosystem |
| Graph DB | Neo4j Community (Docker) | Full ACID, concurrent access, mature Cypher, free |
| Neo4j Driver | neo4j-driver (npm) |
Official, pure JS, TypeScript types, actively maintained |
| MCP Framework | @modelcontextprotocol/sdk |
Standard MCP server toolkit |
| LLM for everything | Claude Code sessions (Max plan) | No API key needed by default — extraction, Cypher gen, reasoning. API-key runner is optional |
| Hook scripts | Node.js CLI scripts | Fast startup, filesystem checks only |
| Testing | Vitest | Lightweight, TypeScript-native |
| Container | Docker (Desktop on Windows/macOS, Engine on Linux) | Runs Neo4j and the MCP server as containers |
This system operates at the user level, not per-project:
- Neo4j container, config, logs, ingest folder all live under
~/graph-memory/ - MCP server registered in
~/.claude/settings.json(user-level) - Hooks registered in user-level settings
- Scheduled tasks are session-agnostic
Project context is captured inside the graph, not by scoping the graph:
- Conversations carry
cwdin the JSONL transcripts - Entities get edges linking them to relevant projects
- Retrieval-time affinity scoring boosts in-project entities
- Cross-project knowledge is preserved
In documentation: ~/ means your home folder. Graph data lives under ~/graph-memory/, and Claude Code transcripts under ~/.claude/projects/.
In code, hooks, scheduled task prompts, and MCP registration: Always use fully qualified absolute paths (e.g. C:\Users\<you>\.claude\... on Windows or /home/<you>/.claude/... on Linux/macOS). Tilde expansion is not guaranteed across all execution contexts on Windows (hooks run as shell commands, scheduled tasks run as Claude sessions, MCP server runs as a Node.js child process — each may handle ~ differently). The MCP server itself uses os.homedir() to resolve at startup; downstream code references the resolved GRAPH_MEMORY_HOME constant.
The MCP server resolves paths at startup using os.homedir() and exposes a GRAPH_MEMORY_HOME constant so all components reference the same resolved path.
- Neo4j listens only on localhost (Docker port binding
127.0.0.1:7687); not exposed externally - MCP server for local clients uses stdio transport (docker exec); for remote clients uses HTTPS via Cloudflare Tunnel
- Remote access is gated by OAuth 2.1: only
/oauth/authorizerequires Cloudflare Access; all other paths use RS256 bearer tokens issued by the server. Only public clients (token_endpoint_auth_method: "none") are supported — confidential clients (client_secret_basic/client_secret_post) are rejected at registration. - No sensitive data stored in graph (entities and relationships only; full content stays in markdown)
- Dream process reads transcripts from local filesystem only
- OAuth keypair at
~/graph-memory/oauth/should be kept out of backups shared externally
- Neo4j data lives in
${GRAPH_MEMORY_HOME}/neo4j/data(a bind mount, not a named volume). See BACKUP.md for cold copies, dumps and JSONL exports. - Schema is
schema/v1.cypherin the repo. The image copies it to/app/schema/. - Markdown files remain the source of truth. If the graph is corrupted, the bootstrap prompt (
/graph-bootstrap) can rebuild from memory files + transcripts. - Config and manifest are small JSON files — include in any backup
- Recovery procedure: Stop container → restore data directory → restart. Or: restore a
graph_exportJSONL with the import script (see BACKUP.md), then re-run bootstrap for anything newer.
{ "mcpServers": { "graph-memory": { "command": "docker", "args": ["exec", "-i", "graph-memory-mcp", "node", "dist/mcp-server/index.js"] } } }