The graph-memory-mcp server is a thin wrapper around Neo4j. It executes Cypher queries and returns results. No LLM calls — all intelligence stays in Claude Code sessions (Max plan). 20 tools total.
This means:
- No API key needed
- No
@anthropic-ai/sdkdependency - Fast and predictable — every tool call is just a database operation
- Claude in the session handles entity extraction, Cypher generation, and reasoning
- Tool annotations: Each tool is annotated with
readOnlyHint,destructiveHint, andidempotentHintso Claude can reason about which tools are safe to call speculatively. - Multi-label nodes: All entity nodes carry
:Entityplus their type label. Tools that search across types use the:Entitylabel; tools that filter use specific labels. - Confidence-max MERGE pattern: Write tools use
MERGEwith a hybrid update strategy — take max for explicit evidence, additive boost for mentions.
Local stdio transport (Claude Code / Claude Desktop on the same machine):
Remote clients (claude.ai web, multi-device) connect via HTTPS to https://your-host.example/mcp using OAuth 2.1 bearer tokens. See REMOTE.md.
import neo4j, { Driver } from 'neo4j-driver';
// Single driver instance, connection pooled
const driver: Driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)
);
// Read queries use executeRead (auto-retry on transient failures)
// Write queries use executeWriteThe driver handles connection pooling, retries, and session management automatically. No lock files needed — Neo4j manages concurrent access internally.
Purpose: Traverse from entities, return relevant subgraph with weights. Applies project-context affinity when provided.
Input:
{
"entities": ["string"],
"entity_types": ["string"],
"max_hops": 2,
"min_weight": 0.3,
"limit": 20,
"project_context": "string",
"context_level": "minimal" | "full" | "relations-only",
"current_only": true
}context_level controls response size (default: full):
minimal— just id, name, type, confidence per node. No properties, no source files. Fast for scanning/listing.full— all properties, source files, edge details with provenance. For deep dives.relations-only— id, name, type per node + all edge details. For graph structure analysis.
current_only (default: true) — when true, only returns edges where invalid_at IS NULL (current facts). Set to false to include superseded edges for historical queries.
Output (context_level: "full"):
{
"nodes": [
{
"id": "project-x",
"type": "Project",
"name": "Project X",
"properties": { "status": "active", "stack": "React, Node.js" },
"confidence": 0.85,
"source_file": "~/.claude/.../project_x.md"
}
],
"edges": [
{
"from": "alice",
"to": "project-x",
"type": "WORKS_ON",
"weight": 0.9,
"effective_weight": 0.9,
"properties": { "role": "lead" },
"valid_at": "2026-03-01T...",
"invalid_at": null,
"source_session": "abc-123",
"source_type": "conversation"
}
],
"source_files": ["~/.claude/projects/<encoded-project>/memory/project_x.md"]
}Note: All paths in MCP server responses use fully resolved Windows paths (via
os.homedir()). The~/shorthand is used only in documentation for readability.
Output (context_level: "minimal"):
{
"nodes": [
{ "id": "project-x", "type": "Project", "name": "Project X", "confidence": 0.85 }
],
"edge_count": 12
}Behavior:
- For each entity name, query using full-text search index (fuzzy)
- Execute Cypher traversal with hop and weight constraints
- If
project_contextprovided: a. Resolve to a Project node (match by directory path or name) b. Calculate hop distance from each result to active project c. Apply affinity multiplier (1.3 at 1 hop, 1.15 at 2 hops) d. Sort by effective weight - Deduplicate and return results with source file paths
Purpose: Create or strengthen relationships between entities. Supports single or batch operations. Creates entities if they don't exist.
Annotation: idempotentHint: true
Input (single):
{
"from_name": "string",
"from_type": "string",
"to_name": "string",
"to_type": "string",
"relation": "string",
"weight": 0.5,
"properties": {},
"evidence": "string",
"valid_at": "datetime",
"source_session": "string",
"source_transcript": "string",
"source_type": "conversation"
}Input (batch — for dream process):
{
"batch": {
"entities": [
{ "localId": "proj-gm", "name": "graph-memory", "type": "Project", "properties": { "status": "active" } },
{ "localId": "neo4j", "name": "neo4j", "type": "Concept", "properties": { "category": "technology" } },
{ "localId": "alice", "name": "alice", "type": "Person" }
],
"relations": [
{ "from": "alice", "to": "proj-gm", "relation": "WORKS_ON", "weight": 0.7, "properties": { "role": "lead" } },
{ "from": "proj-gm", "to": "neo4j", "relation": "USES_TECH", "weight": 0.6 }
],
"source_session": "abc-123",
"source_transcript": "~/.claude/projects/.../abc-123.jsonl",
"source_type": "conversation"
}
}The localId field allows referencing entities within the same batch call before they have real IDs. The from and to fields in relations match against localId first, then fall back to existing entity name lookup. This enables creating a connected subgraph atomically in a single call — critical for dream process efficiency.
Output (single):
{
"action": "created" | "strengthened",
"edge": { "from": "...", "to": "...", "type": "...", "weight": 0.65 },
"created_entities": ["entity-name"]
}Output (batch):
{
"entities_created": 3,
"entities_merged": 1,
"edges_created": 2,
"edges_strengthened": 0,
"details": [
{ "entity": "graph-memory", "action": "created" },
{ "entity": "neo4j", "action": "merged" },
{ "edge": "alice→WORKS_ON→graph-memory", "action": "created", "weight": 0.7 }
]
}Behavior:
- Single mode: Uses
MERGEto find-or-create both nodes and the relationship in a single transaction. If the relationship exists, applies confidence-max update (take max of current and new weight for explicit evidence, additive boost for mentions). Setsvalid_atif provided. Atomic. - Batch mode: Resolves all
localIdreferences, creates all entities, then creates all relationships in a single Neo4j transaction. If any step fails, the entire batch rolls back. Provenance fields (source_session,source_transcript,source_type) are applied to all edges in the batch. - Supersession handling: If a new edge contradicts an existing one (same endpoints, same type, different target), the old edge's
invalid_atis set to the new edge'svalid_atrather than creating a CONTRADICTS relationship. This is the default for factual updates. True contradictions (ambiguous, unresolved) still use the CONTRADICTS edge type.
Purpose: Increase edge weight when user confirms recalled information.
Input:
{
"from_name": "string",
"to_name": "string",
"relation": "string",
"amount": 0.15,
"reason": "string"
}Output:
{
"previous_weight": 0.6,
"new_weight": 0.75,
"edge": { "from": "...", "to": "...", "type": "..." }
}Purpose: Decrease edge weight when user corrects a fact.
Input:
{
"from_name": "string",
"to_name": "string",
"relation": "string",
"amount": 0.2,
"reason": "string"
}Output:
{
"previous_weight": 0.7,
"new_weight": 0.5,
"edge": { "from": "...", "to": "...", "type": "..." },
"pruned": false
}Purpose: Browse/search the entity catalog.
Input:
{
"search": "string",
"type": "string",
"min_confidence": 0.0,
"sort_by": "confidence" | "last_seen" | "name",
"limit": 20
}Output:
{
"entities": [
{
"id": "project-x",
"type": "Project",
"name": "Project X",
"confidence": 0.85,
"edge_count": 12,
"last_seen": "2026-04-06T...",
"source_file": "..."
}
],
"total": 47
}Purpose: Find unresolved contradictions across all node types.
Input:
{
"include_resolved": false
}Output:
{
"contradictions": [
{
"node_a": { "id": "...", "type": "Fact", "content": "Project X uses Vue" },
"node_b": { "id": "...", "type": "Fact", "content": "Project X uses React" },
"description": "Conflicting tech stack for Project X",
"detected_date": "2026-04-05T...",
"resolved": false
}
],
"count": 1
}Purpose: Queue or immediately process a document for ingestion. Status check for ingest queue.
Input:
{
"action": "queue" | "status",
"file_path": "string",
"content": "string",
"file_name": "string",
"meta": {
"source": "string",
"author": "string",
"date": "string",
"topic_hints": ["string"],
"weight_override": 0.6,
"session_id": "string",
"through": "ISO timestamp",
"last_event_id": "string"
}
}For queue, give either file_path (a file on the server, copied into pending/) or content + file_name (text written straight into pending/). Inline content is for remote callers that can't reach the server's filesystem, such as the cloud-session collector. It is admin-tenant only, capped at 2 MB, and file_name must be a plain basename ending .md, .txt or .json.
Note: action: "process" (immediate ingestion) is handled by the /ingest --now skill, which runs entity extraction in the Claude Code session and calls graph_relate directly. The MCP tool only handles queueing (filesystem copy) and status (filesystem read). No LLM needed.
Output (queue):
{
"action": "queued",
"file": "karpathy-llm-wiki.srt",
"destination": "~/graph-memory/ingest/pending/karpathy-llm-wiki.srt",
"meta_written": true
}Output (status):
{
"pending": [
{ "file": "meeting-notes.md", "queued_at": "2026-04-06T14:30:00Z", "size": "2.1 KB" }
],
"recently_completed": [
{ "file": "karpathy-llm-wiki.srt", "processed_at": "2026-04-06T23:00:00Z", "entities": 12, "edges": 18 }
],
"pending_count": 1,
"completed_count": 5,
"session_cursors": {
"session_01abc": { "through": "2026-10-19T04:58:00Z", "last_event_id": "cmsg_01...", "file": "cloud-session_01abc-20261019045800.md" }
}
}session_cursors lists, per session, the newest meta.through among pending/ and completed/ sidecars whose meta.source is "claude-code-cloud". The cloud-session collector uses it to queue only new messages.
Purpose: Execute a pre-formed Cypher query against Neo4j. Read-only. Claude generates the Cypher in the session — this tool just runs it.
Input:
{
"cypher": "string",
"params": {}
}Output:
{
"cypher": "MATCH (p:Person)-[w:WORKS_ON]->(proj:Project) ...",
"results": [
{ "p.name": "alice", "proj.name": "graph-memory", "w.weight": 0.85 }
],
"result_count": 1,
"execution_time_ms": 12
}Safety:
- Primary defense —
executeReadtransaction mode: The query is executed via Neo4j's read-only transaction (session.executeRead()). Neo4j itself rejects ANY write operation at the database level, regardless of what the Cypher contains. This is the definitive guard — it cannot be bypassed by clever Cypher construction, Unicode tricks, or comment injection. No application-level clause whitelist is needed. - 5-second query timeout to prevent runaway traversals.
- Writes go through dedicated tools (
graph_relate,graph_boost,graph_prune, etc.) with proper validation.
Purpose: Apply time-based decay to all node confidence and edge weights. Called by the dream process during maintenance.
Input:
{
"dry_run": false
}Output:
{
"nodes_decayed": 45,
"edges_decayed": 120,
"nodes_flagged_for_pruning": 3,
"edges_below_threshold": 7,
"details": [
{ "type": "Project", "name": "old-project", "old_confidence": 0.15, "new_confidence": 0.12 }
]
}Behavior:
Runs a single Cypher query per node type that calculates decay based on last_seen / last_confirmed timestamps and the configured decay rates. Uses Neo4j transactions so it's all-or-nothing — no partial decay on failure (fixes reviewer's concern about crash mid-maintenance).
// Example: decay all Project nodes
MATCH (n:Project)
WHERE n.last_seen < datetime() - duration('P1D')
WITH n, n.confidence * (0.995 ^ duration.between(n.last_seen, datetime()).days) AS new_conf
SET n.confidence = CASE WHEN new_conf < 0.01 THEN 0.01 ELSE new_conf END
RETURN count(n) AS decayedPurpose: Remove entities and edges that have decayed below threshold. Provides a preview before destructive action. Called after reviewing graph_decay results or graph_find stale output.
Annotation: destructiveHint: true
Input:
{
"mode": "preview" | "execute",
"node_threshold": 0.1,
"edge_threshold": 0.05,
"include_orphans": true,
"max_age_days": 90
}mode: "preview"(default) — returns what would be pruned without deleting anything.mode: "execute"— actually deletes. Requires explicit confirmation from the user (Claude should ask before calling with execute).node_threshold— prune nodes with confidence below this AND no edges with weight aboveedge_threshold. Defaults to config value (decay.prune_node_threshold).edge_threshold— prune edges with weight below this. Defaults to config value (decay.prune_edge_threshold).include_orphans— also prune nodes with zero edges, regardless of confidence, iflast_seenis older thanmax_age_days.max_age_days— age threshold for orphan pruning (default: 30, from configdecay.prune_orphan_days).
Output (preview):
{
"mode": "preview",
"nodes_to_prune": [
{ "id": "old-project", "type": "Project", "name": "Old Project", "confidence": 0.08, "last_seen": "2025-11-15T...", "edge_count": 0 }
],
"edges_to_prune": [
{ "from": "alice", "to": "old-project", "type": "WORKS_ON", "weight": 0.03 }
],
"node_count": 1,
"edge_count": 1
}Output (execute):
{
"mode": "executed",
"nodes_pruned": 1,
"edges_pruned": 1,
"details": [
{ "action": "deleted_node", "id": "old-project", "type": "Project" },
{ "action": "deleted_edge", "from": "alice", "to": "old-project", "type": "WORKS_ON" }
]
}Behavior:
- Query for all nodes with
confidence < node_thresholdthat have no edges withweight > edge_threshold - Query for all edges with
weight < edge_threshold - If
include_orphans: also find nodes with zero edges andlast_seenolder thanmax_age_days - In preview mode: return the lists without modifying anything
- In execute mode: delete edges first, then nodes, in a single transaction
- Alias nodes pointing to pruned entities are also removed
Purpose: Split a falsely merged entity back into two separate nodes, redistributing edges. Reverses a bad merge using the merge audit log.
Why this exists: The reviewer identified that false entity merges are silent and catastrophic. "Anna" and "Anne" merged = every query about either returns contaminated results. This tool provides an undo path.
Input:
{
"entity_id": "string", // the merged entity to split
"new_entity_name": "string", // name for the split-off entity
"new_entity_type": "string", // node label for the split-off entity
"edges_to_move": [ // edges identified by composite key (no internal IDs)
{
"other_entity_id": "string", // the entity on the other end of the edge
"relation_type": "string", // e.g. "WORKS_ON", "KNOWS_ABOUT"
"direction": "in" | "out" // relative to the entity being split
}
],
"reason": "string" // why splitting (logged in merge audit)
}Output:
{
"original": { "id": "anna", "name": "Anna", "remaining_edges": 8 },
"new_entity": { "id": "anne", "name": "Anne", "moved_edges": 3 },
"audit_logged": true
}Behavior:
- Create the new entity node with specified properties
- For each edge in
edges_to_move:- Match the edge using (entity_id)--[relation_type]--(other_entity_id) with direction
- Delete the edge from the original node
- Create an equivalent edge on the new node (preserving weight, all properties)
- Log the split in the merge audit log (
~/.claude/graph-memory/logs/merge-audit.jsonl) - Return summary of what was moved
Edge identification: Edges are identified by composite key (other_entity_id + relation_type + direction) rather than internal Neo4j IDs, which are not stable across restarts. If multiple edges exist between the same pair with the same type (rare), all are moved.
If edges_to_move is empty: Claude can use graph_query on the merged entity first to inspect all edges, then decide which to move. The /graph-ask skill makes this easy.
Purpose: Surface candidate pairs of entities likely to be the same thing — the read-only counterpart to graph_unmerge. LLM-driven extraction (the dream process) reliably produces near-duplicates as the graph grows; this tool flags them for human review.
Why this exists: Entity-explosion is the natural failure mode of any LLM-driven extraction pipeline. Without a way to surface duplicates, they pile up and degrade retrieval quality silently. This tool gives operators a triage queue.
Input:
{
"entity_id": "string", // optional — scope to one entity's duplicates
"entity_type": "string", // optional — scope to one type (Person, Project, …)
"min_score": 0.8, // optional — combined-score threshold (default 0.8)
"min_embedding_similarity": 0.85, // optional — vector floor (default 0.85)
"limit": 20, // optional — max suggestions returned (default 20, max 100)
"weights": { // optional — override default 0.4/0.4/0.2
"embedding": 0.4,
"neighbor_jaccard": 0.4,
"name": 0.2
},
"log_to_audit": true // optional — emit merge_flagged events (default true)
}Output:
{
"suggestions": [
{
"entity_a": { "id": "postgres", "name": "Postgres", "type": "Technology", "edge_count": 7 },
"entity_b": { "id": "postgresql", "name": "PostgreSQL", "type": "Technology", "edge_count": 4 },
"score": 0.92,
"signals": {
"embedding_similarity": 0.97,
"name_similarity": 0.50,
"neighbor_jaccard": 0.85,
"shared_neighbors": [
{ "id": "graph-memory", "relation": "USES_TECH", "degree": 28, "weight": 0.23 },
{ "id": "letters-workspace", "relation": "USES_TECH", "degree": 3, "weight": 0.48 }
]
},
"recommended_action": "review"
}
],
"total_pairs_evaluated": 412,
"threshold_used": 0.7,
"scope": { "entity_type": "Technology", "global": false }
}Scoring:
score = w.embedding · embedding_similarity
+ w.neighbor_jaccard · weighted_jaccard(N(a), N(b))
+ w.name · |tokens(a) ∩ tokens(b)| / |tokens(a) ∪ tokens(b)|
weighted_jaccard(A, B) = Σ weight(n) for n ∈ (A ∩ B)
/ Σ weight(n) for n ∈ (A ∪ B)
weight(n) = 1 / (1 + ln(degree(n)))
Same-type is a hard gate — never suggests cross-type merges. The weighted Jaccard is hub-aware: shared neighbors that are themselves connected to many other entities (e.g. the user's own Person node, with ~50 edges) contribute little to the match score, while shared neighbors with few connections contribute close to 1.0. This prevents the false-positive cluster where every pair of entities owned by the same person looks like a duplicate.
Hub weight examples: degree 1 → 1.0, degree 2 → 0.59, degree 5 → 0.38, degree 10 → 0.30, degree 50 → 0.20, degree 100 → 0.18.
Behavior:
- Build seed set from
entity_id/entity_type/ global (capped at 200 seeds for safety) - For each seed, query the
entity_embeddingvector index for top-10 same-type neighbors abovemin_embedding_similarity - Dedupe pairs (canonicalised so
a.id < b.id) - For each pair, fetch names + neighbor sets in one Cypher query, compute Jaccard signals
- Apply weights, filter by
min_score, rank, return toplimit - If
log_to_audit(default true), emit onemerge_flaggedevent per surfaced pair todream-audit.jsonl
Performance: O(seeds × top_k) vector queries. entity_id-scoped runs in <500ms for any single entity. entity_type-scoped or global runs scale with the seed cap (200 × 10 = 2000 vector queries, ~20s on the 384-dim cosine index).
When to use:
- Periodically (weekly?) — global scan to triage the duplicate backlog
- Right after a heavy ingest — type-scoped scan on the entity types most likely affected
- Reactively — when you suspect a specific entity has duplicates, scope to its
entity_id
Pairs with: graph_unmerge (inverse — splits false merges) and an as-yet-unimplemented graph_merge tool (executes an approved merge). Until that lands, surfaced pairs can be merged manually via graph_relate to add an ALIAS_OF edge plus targeted graph_delete on the duplicate.
Purpose: Graph health dashboard.
Input: none
Output:
{
"schema_version": "1",
"nodes": {
"total": 127,
"by_type": { "Person": 15, "Project": 8, "Preference": 23, "Concept": 45, "Decision": 12, "Fact": 20, "Event": 9, "Object": 14, "Alias": 4 }
},
"edges": {
"total": 312,
"by_type": { "WORKS_ON": 18, "PREFERS": 23, "KNOWS_ABOUT": 45, "USES_TECH": 30, "RELATED_TO": 180, "PARTICIPATED_IN": 12, "USES": 8, "CONTRADICTS": 2 }
},
"health": {
"avg_weight": 0.52,
"orphaned_nodes": 3,
"unresolved_contradictions": 1,
"stale_nodes": 8,
"last_dream_run": "2026-04-06T23:00:00Z",
"pending_ingest_docs": 1,
"total_ingested_docs": 5
}
}Purpose: Delete a specific entity and all its edges from the graph.
Annotation: destructiveHint: true
Input:
{
"entity_id": "string",
"reason": "string"
}Output:
{
"deleted": true,
"entity": { "id": "...", "name": "...", "type": "..." },
"edges_removed": 4
}When to use: When an entity is clearly wrong, a test artifact, or was created by mistake. Claude should confirm with the user before calling.
Purpose: Read a session JSONL transcript via the canonical parser, returning normalized text messages. Shields dream process from JSONL format variations between Claude Code versions.
Input:
{
"session_id": "string",
"file_path": "string",
"text_only": true
}Provide either session_id (resolved to ~/.claude/projects/**/<session_id>.jsonl) or an explicit file_path. text_only (default true) strips tool calls and returns only human/assistant text.
Output:
{
"messages": [
{ "role": "human", "text": "...", "timestamp": "...", "session_id": "...", "cwd": "..." }
],
"message_count": 42,
"session_id": "...",
"cwd": "..."
}When to use: In the dream process before entity extraction. Prefer this over raw file reads.
Purpose: Append a structured event to ~/graph-memory/logs/dream-audit.jsonl. Used by the dream process at run checkpoints for observability.
Input:
{
"event": "run_start | run_end | transcript_start | transcript_end | transcript_skipped | entity_created | edge_created | edge_modified | merge_flagged | contradiction_found | ingest_start | ingest_end | decay_applied | format_warning | error",
"data": {}
}Output:
{ "logged": true, "timestamp": "..." }When to use: Called by the dream process at the start/end of each run and at key milestones. Not needed during normal conversation.
Purpose: Write a timestamped JSONL backup of all nodes and edges to ~/graph-memory/backups/. Prunes old backups beyond the retention count.
Input:
{
"keep": 14,
"label": "string"
}keep (default 14) is the number of backups to retain. label is an optional suffix for the filename (e.g. "pre-migration").
Output:
{
"backup_file": "~/graph-memory/backups/2026-05-06T12-00-00.jsonl",
"node_count": 297,
"edge_count": 418,
"size_bytes": 184320,
"pruned": 2,
"retained": 14
}When to use: Before schema migrations, before bulk imports, or on a scheduled basis via the /graph-backup skill. This is one of two backup layers — see docs/BACKUP.md for the full runbook covering volume snapshots, logical exports, and disaster-recovery scenarios.
Purpose: Scan the graph for quality issues — generic names, reference language ("the X", "this X"), orphaned low-confidence entities, and near-duplicates. Returns a prioritized issues list.
Input:
{
"source_session": "string",
"max_issues": 50
}source_session (optional) scopes the scan to entities created in one session. max_issues (default 50) caps the result size.
Output:
{
"issues": [
{
"severity": "high | medium | low",
"type": "generic_name | reference_language | orphan | near_duplicate",
"entity_id": "...",
"entity_name": "...",
"description": "Name looks like a reference: 'the project'"
}
],
"issue_count": 7
}When to use: After bootstrapping, after a large dream run, or periodically during the weekly deep dream.
Purpose: Cluster entities into communities using greedy seed-based BFS through edges above a weight threshold. No GDS or APOC plugin required.
Input:
{
"weight_threshold": 0.4,
"max_communities": 10,
"max_hops": 3,
"min_size": 2
}max_hops capped at 4.
Output:
{
"communities": [
{
"id": 1,
"seed_name": "graph-memory",
"member_count": 12,
"dominant_type": "Project",
"members": [ { "id": "...", "name": "...", "type": "..." } ]
}
],
"total_members": 45,
"coverage_pct": 72.5
}When to use: During the weekly deep dream to surface topic clusters, or when you want to understand the graph's major knowledge areas.
Purpose: Bundle graph health, pending work, last dream run, recent additions, top hubs, contradictions, and an optional topic neighbourhood into a single call. Replaces 4–5 sequential round trips at session start.
Input:
{
"topic": "string",
"project_cwd": "string",
"recent_days": 7,
"hub_count": 5,
"include_contradictions": true,
"max_recent": 15
}All fields optional. topic triggers a neighbourhood expansion around the named entity.
Output: Combined object containing stats, recent_entities, top_hubs, contradictions (if include_contradictions), and topic_neighbourhood (if topic provided).
When to use: At session start via /graph-briefing, or whenever you want a full context snapshot in one tool call.
Purpose: Hybrid retrieval — embeds the query using the local bge-small-en model, finds top-K similar entities via Neo4j's vector index, then optionally expands into the surrounding graph via traversal.
Input:
{
"query": "string",
"top_k": 10,
"min_similarity": 0.5,
"entity_types": ["string"],
"expand": true,
"expand_min_weight": 0.3
}query is required. entity_types filters results to specific node labels. expand (default true) returns connected nodes and edges around the seed matches.
Output:
{
"seeds": [
{ "id": "...", "name": "...", "type": "...", "similarity": 0.87 }
],
"expansion": {
"nodes": [ { "id": "...", "name": "...", "type": "..." } ],
"edges": [ { "from": "...", "to": "...", "type": "...", "weight": 0.7 } ]
}
}When to use: When graph_query (keyword-based) doesn't surface the right entities — e.g. for conceptually related queries or when the exact entity name is unknown.
Purpose: Regenerate embeddings for entities. By default fills missing embeddings only (safe to run anytime, idempotent). force: true re-embeds all entities — use after changing the embed-text recipe.
Input:
{
"force": false
}Output:
{
"embedded": 42,
"skipped": 255,
"errors": 0
}When to use:
| Trigger | force |
Why |
|---|---|---|
After graph_merge |
false (default) |
The merge clears target.embedding; default mode picks it up next call. |
Bulk-import or restore from graph_export finished |
false |
Imported nodes arrive without embeddings (export is JSONL only). |
Embed-text recipe changed (new fields included in buildEmbedText) |
true |
Existing embeddings reflect the old recipe; semantic-search quality drifts until rebuilt. |
| Embedding model swapped (different model id, dimensions, or version) | true |
Old vectors are no longer comparable to query vectors from the new model. |
graph_search returns obviously poor results despite reasonable queries |
true |
Last-resort diagnostic; consider checking the embed-text recipe first. |
When NOT to run it: Don't include in routine maintenance. The default (force: false) is idempotent and cheap, but force: true re-embeds every node (~10ms per entity, so a few hundred nodes finish in seconds, but multi-thousand-node graphs aren't free). Routine writes already embed at creation time — graph_reembed is for catching gaps and recovering from changes that invalidate prior vectors.
Tenant scope: When called by an admin tenant, re-embeds across all tenants. Otherwise scoped to the caller's tenant.
| Error | Response |
|---|---|
| Entity not found | Return empty result with "found": false |
| Duplicate entity on create | Merge with existing via MERGE, return "action": "merged" |
| Edge not found (for boost/weaken) | Return error suggesting graph_relate instead |
| Invalid weight (out of range) | Clamp to [0.0, 1.0], return warning |
| Neo4j connection failure | Return error: "graph unavailable — is the Docker container running?" |
| Cypher query timeout (>5s) | Kill query, return error suggesting narrower question |
| Neo4j transaction failure | Auto-retry (driver handles transient failures) |
| Operation | Target Latency |
|---|---|
| graph_query (2 hops) | < 100ms |
| graph_relate | < 50ms |
| graph_boost / graph_weaken | < 30ms |
| graph_entities (search) | < 100ms |
| graph_contradictions | < 200ms |
| graph_cypher (read-only) | < 5s (timeout) |
| graph_decay (full graph) | < 2s |
| graph_ingest (queue) | < 100ms |
| graph_ingest (status) | < 50ms |
| graph_prune (preview) | < 200ms |
| graph_prune (execute) | < 500ms |
| graph_unmerge | < 200ms |
| graph_merge_suggestions (entity_id-scoped) | < 500ms |
| graph_merge_suggestions (entity_type-scoped or global) | scales with seed cap; ~10–20s typical |
| graph_stats | < 150ms |
All easily achievable for a personal-scale graph on local Neo4j.
All configuration values have hardcoded defaults compiled into the MCP server. The config.json file is an optional override — if it's missing, corrupted, or has missing keys, the server uses sensible defaults and logs a warning.
// Hardcoded defaults (compiled into server)
// NOTE: Neo4j credentials come from environment variables ONLY (NEO4J_URI, NEO4J_USER,
// NEO4J_PASSWORD). They are NOT hardcoded here to avoid leaking into source control.
const DEFAULTS = {
neo4j: {
uri: process.env.NEO4J_URI || "bolt://localhost:7687",
user: process.env.NEO4J_USER || "neo4j",
password: process.env.NEO4J_PASSWORD // REQUIRED — no fallback, fail if missing
},
weights: {
explicit_statement: 0.7,
inferred: 0.3,
from_memory_file: 0.5,
boost_on_confirm: 0.15,
boost_on_mention: 0.05,
weaken_on_correct: 0.3,
project_context_boost: 0.10
},
decay: {
rates: { Person: 0.998, Project: 0.995, Preference: 0.999, Concept: 0.999, Decision: 0.997, Fact: 0.996, Event: 0.993, Object: 0.996 },
edge_rate: 0.997,
prune_node_threshold: 0.1,
prune_edge_threshold: 0.05
},
query: {
default_max_hops: 2,
default_min_weight: 0.3,
default_limit: 20,
cypher_timeout_ms: 5000
},
affinity: {
hop_1_multiplier: 1.3,
hop_2_multiplier: 1.15
}
};Config loading: config.json values deep-merge over defaults. Missing keys use defaults. Invalid values (e.g., weight > 1.0) are clamped with a warning.
Neo4j handles this natively:
- Multiple simultaneous Bolt connections are fully supported
- Readers don't block writers
- Write-write conflicts on the same node are handled via internal locking (auto-retry by driver)
- MCP server and dream process each maintain their own driver instance
- No application-level lock files needed
- Neo4j transactions ensure atomicity — no partial writes on crash