使用说明
Graph Memory MCP
Graph memory for teams and their agents that doesn't turn into a dump.
Self-hosted MCP server for agent developers, teams running agents, and heavy agent users. Agents decide what facts to write; the server keeps memory orderly—deduplicates on insert, marks outdated facts, surfaces contradictions, and enforces physical per-owner isolation. Your data stays with you. No LLM calls on the server, no external APIs. Embeddings computed locally.
The GIF is a recording of the demo script running against a real server. Run it yourself with uv run graph-memory-mcp --host 127.0.0.1 --port 8000, then uv run python assets/demo/seed.py and uv run python assets/demo/generate_demo.py --scenario readme, and read assets/demo/seed.py and assets/demo/generate_demo.py to see exactly what happens.
Other recordings: docs/demos.md.
Why Graph Memory MCP
- Traversable graph: Agents read edges (
get_context,get_trace) to walk connections between facts—decisions link to affected services, new factsFOLLOWS_FROMold ones - Explicit outdating:
mark_outdatedhides superseded facts;CONTRADICTSedges surface conflicts incontradiction_pairs; versioning tracks changes over time - Write-time deduplication: Near-duplicates refused at insert using embeddings only (threshold 0.92), no LLM call, deterministic
- Physical isolation: Each
owner_idgets its own FalkorDB graph or ArcadeDB database—no query-time filtering risks
Quickstart
cp env.example .env
docker compose up -d arcadedb # Start ArcadeDB (default backend)
uv run graph-memory-mcp --host 127.0.0.1 --port 8000 # Start MCP server
claude mcp add --transport http graph-memory-mcp http://127.0.0.1:8000/mcp
See full setup instructions for Cursor and Claude Desktop.
Learn more: Comparison with other systems · FAQ · Limitations · Agent policy cheatsheet
How it works
Agent memory becomes noise over time: duplicate facts accumulate, contradictions are served as current truth, stale decisions look fresh, and context grows until retrieval becomes a dump. Every session starts from scratch, and teams end up with unstructured notes nobody trusts.
Design principle: The agent using the memory already knows what to write and how to structure it. The server does not run its own LLM to extract or rewrite facts; it enforces memory hygiene (dedup on write, outdated facts, contradictions, versions, isolation, token budget). Benefits: predictable, cheap, no hidden model calls, no double extraction.
Before / After: Why a graph
A graph lets agents traverse connections, supersede decisions, and surface contradictions.
Before: Decision made
# Alice decides to use Component X for the API gateway
create_node(
text="API gateway uses Component X for routing.",
metadata={"project": "api", "created_by": "alice"},
owner_id="team_backend",
)
# → {"node": {"node_id": "a1b2c3...", "text": "API gateway uses Component X for routing.", "status": "active"}}
# Link decision to affected service
create_relation(
from_id="a1b2c3...",
to_id="d4e5f6...", # Service node for API gateway
relation_type="RELATED_TO", # Or USES: service USES this decision
owner_id="team_backend",
)
Traverse connections
# Get context: what decisions affect the API gateway?
get_context(node_id="d4e5f6...", owner_id="team_backend", depth=1)
# → Returns: API gateway node + connected decision + relation path
# Or: recall with context expansion
recall_context(
query="API gateway decisions", owner_id="team_backend", depth=1, compact=True
)
# → Returns snippets + paths showing decision→service links
After: Decision superseded
# Bob changes to Component Y
new_decision = create_node(
text="API gateway migrated to Component Y (Oct 2026).",
metadata={"project": "api", "created_by": "bob"},
owner_id="team_backend",
)
new_id = new_decision["node"]["node_id"] # Extract node_id from response
# Mark old decision outdated
mark_outdated(
fact_id="a1b2c3...", reason="Migrated to Component Y", owner_id="team_backend"
)
# Link new → old (new follows from the context of replacing old)
create_relation(
from_id=new_id,
to_id="a1b2c3...",
relation_type="FOLLOWS_FROM", # New decision follows from old context
owner_id="team_backend",
)
Result: search("API gateway", owner_id="team_backend") now returns Component Y decision. Old decision excluded by default; include_outdated=true retrieves history. The FOLLOWS_FROM edge preserves the replacement context.
Note: Custom relation types (e.g., DEPENDS_ON, IMPLEMENTS) can be added via RELATION_ALLOWED_TYPES in .env. Default allowlist (13, see relation_policy.py): RELATED_TO, MENTIONS, SUMMARIZES, FOLLOWS_FROM, CONTRADICTS, EXTRACTED_FROM, SIMILAR_TO, NEXT_STEP, EXPOSES, CROSS_REF, RUNS_ON, USES, SERVED_BY. Default mode: warn on unlisted types. auto_link writes MENTIONS (AUTO_LINK_RELATION), not a type named AUTO_LINK.
Contradictions surfaced
# Two conflicting facts stored
create_node(text="API rate limit is 1000 req/min.", ...) # id: x1
create_node(text="API rate limit is 500 req/min.", ...) # id: x2
create_relation(from_id="x1", to_id="x2", relation_type="CONTRADICTS", ...)
# Search returns both + flags the conflict
search(query="API rate limit", owner_id="team_backend")
# → {
# "results": [...],
# "contradiction_pairs": [
# {"from_id": "x1", "to_id": "x2", "relation_type": "CONTRADICTS"}
# ]
# }
How agents should use this
- Start every session with
get_brief(owner_id)(project anchor, recent facts, contradictions, stale count) →search(query, compact=true)for the task - Write one declarative fact per node; no chat logs, no scratchpad, no secrets
- Search before creating; if
memory_duplicateorpossible_duplicatesreturned, do not write a third paraphrase - Fact became wrong →
mark_outdated(fact_id, reason, owner_id="team_backend")+create_node(new_text), notupdate_node - After meaningful work (GROW): Ground what changed → Record it → Orient (if recurring, add tags/relations) → Write with
metadata.projectandcreated_by - Temporary notes get
ttl_days(14-30); durable decisions have no TTL - Link facts with
create_relationusing the default allowlist:RELATED_TO,MENTIONS,SUMMARIZES,FOLLOWS_FROM,CONTRADICTS,EXTRACTED_FROM,SIMILAR_TO,NEXT_STEP,EXPOSES,CROSS_REF,RUNS_ON,USES,SERVED_BY(seerelation_policy.py; default mode: warn).auto_linkwritesMENTIONS
Full policies: Load docs/memory_policies_for_LLM.md or docs/memory_policy_cheatsheet.md in your agent's rules or context at session start. Put these operator constants in your own AGENTS.md (docs/AGENTS_example.md).
Use cases
Team architecture decisions
Before: Architecture decisions live in Slack threads, meeting notes, and stale wiki pages. Finding "why did we choose Component X?" requires asking the team or searching multiple tools.
With Graph Memory MCP: When Alice documents "API gateway uses Component X" in Claude Desktop, the decision is stored with create_node. Three months later, Bob migrates to Component Y and marks the old fact outdated with mark_outdated, linking the new decision FOLLOWS_FROM the old. Any agent querying "what gateway component do we use?" gets only the current fact. get_trace walks back through the graph to show the full decision history.
Coding agent across sessions
Before: Cursor or Claude Code agents rediscover the same information session after session—re-reading config files, re-asking "what port does staging use?"—because memory resets with each new chat.
With Graph Memory MCP: When the agent finds "staging database uses port 5433" in config/staging.yaml, it writes back with create_node(text="The staging database uses port 5433.", metadata={"source": "config/staging.yaml"}) (the write-back rule in the cheatsheet). Next session, search("staging database port") retrieves it. No file re-reads, no extraction cost.
Multiple agents sharing memory
Before: Three agents each encounter "retry policy: 3 retries" at different times, storing near-duplicate facts. Later, one agent finds "retry policy: 5 retries"—now two contradictory decisions coexist with no signal that they conflict.
With Graph Memory MCP: Write-time deduplication (embeddings, threshold 0.92) refuses the second and third paraphrase of "retry policy: 3 retries." When an agent writes "retry policy: 5 retries," it explicitly creates a CONTRADICTS edge. Any agent searching "retry policy" sees contradiction_pairs in results and can surface the conflict to the team.
Separate clients and projects
Before: All memory is mixed in one index or database, requiring query-time filters (user_id, project_id) that risk leaks if filters are forgotten or misconfigured.
With Graph Memory MCP: Claude Desktop connects with owner_id=team_backend, Cursor with owner_id=team_frontend. Each owner gets a physically separate FalkorDB graph or ArcadeDB database—no shared tables, no query-time filtering. Self-hosted, so data stays on your infrastructure.
When something else fits better
See docs/comparison.md for detailed comparisons with Mem0, Graphiti, Zep, Letta, Cognee, and other memory systems.
Limitations
- No fact extraction on the server: The agent decides what to write; raw chat logs are not auto-summarized (by design).
searchhas noas_ofparameter yet: Onlyget_nodesupports temporal queries;search(as_of)is planned in the roadmap.- Requires a running graph database: ArcadeDB (default) or FalkorDB via Docker.
- First model download is large: the default fp32 ONNX weights for
intfloat/multilingual-e5-baseare about 1.1 GB. Quantized weights are not used.EMBEDDING_BACKEND=torchneedsuv sync --extra torch. - Deduplication is similarity-based: Near-duplicates are refused at insert using embeddings (threshold 0.92); may miss paraphrases below threshold or block distinct-but-similar facts.
- Contradictions are not auto-detected: The agent must create
CONTRADICTSedges explicitly; they are surfaced in search results but not inferred semantically by the server. - Owner access is
OWNER_SCOPE:client(default) uses the tool argumentowner_id.headerignores it and readsX-Owner-Id(loopback bind; the gateway sendsAuthorization: Bearer $ADMIN_TOKENwhen that token is set).tokenrequires a Bearer API key;owner_idmust be one of the key's projects, and an empty value uses the first project. There is no multi-tenant admin UI.
Setup
1. Configure environment
cp env.example .env
The defaults work with docker-compose.yml (ArcadeDB: root / playwithdata, port 2480; FalkorDB: password falkordb123, port 6379).
2. Start the database
docker compose up -d arcadedb
This starts ArcadeDB (port 2480, default backend). To use FalkorDB instead, set GRAPH_BACKEND=falkordb in .env and run docker compose up -d falkordb.
3. Install and run
From this repository:
uv sync
uv run graph-memory-mcp --host 127.0.0.1 --port 8000
Without a clone, the same long-running process comes from PyPI:
pip install graph-memory-mcp
graph-memory-mcp --host 127.0.0.1 --port 8000
The default install embeds with ONNX Runtime (EMBEDDING_BACKEND=onnx). To use the previous sentence-transformers path: uv sync --extra torch and set EMBEDDING_BACKEND=torch.
The server is now at http://127.0.0.1:8000/mcp (Streamable HTTP MCP).
4. Configure your MCP client
Add this to your MCP configuration file:
Claude Code (CLI):
claude mcp add --transport http graph-memory-mcp http://127.0.0.1:8000/mcp
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows):
Claude Desktop launches only local (stdio) servers from claude_desktop_config.json, so bridge to the HTTP server with mcp-remote:
{
"mcpServers": {
"graph-memory-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:8000/mcp"]
}
}
}
Cursor (~/.cursor/mcp.json):
{
"mcpServers": {
"graph-memory-mcp": {
"url": "http://127.0.0.1:8000/mcp"
}
}
}
See examples/mcp_servers_config.json for clients that read an mcpServers dict with a transport key (e.g. langchain-mcp-adapters).
5. Test
uv run python examples/http_client_usage.py
With mcp>=2.3 the Python client is mcp.client.streamable_http.streamable_http_client (the old streamablehttp_client name is gone). Pass headers such as Authorization through an httpx.AsyncClient: streamable_http_client(url, http_client=httpx.AsyncClient(headers=...)).
Features
create_node/upsert_nodewith deduplication (embeddings-based refusal at threshold 0.92, no LLM call)searchwith hybrid ranking (cosine + identifier match + recency),compact=truefor token-budgeted snippetsget_brieffor task warmup (project anchor, recent durable facts, open contradictions, stale count)mark_outdated/CONTRADICTSedges / fact versioning with optional snapshotsget_context(subgraph walk from a node) /get_trace(paths between two nodes) /recall_context(search + expand in one call)- Entity and triplet graph (
create_triplet,search_triplets) - TTL facts that stay out of default search until
include_ttl=true - Auto-linking (opt-in
auto_link=trueaddsMENTIONSto similar Entity nodes) - Background jobs (deduplication, archival) when
JOBS_ENABLED=true - Backed by ArcadeDB (default) or FalkorDB (set
GRAPH_BACKEND=falkordb). Same MCP tools, same behavior.
Documentation
- Comparison — how this differs from Mem0, Graphiti, Letta, and others
- Agent policies — what agents should store and how (load this in every session)
- API contract — tool signatures and data model
- FAQ — common questions and operational guidance
Configuration
Settings are loaded from .env and environment variables. Defaults are in graph_memory_mcp/config.py.
Key settings:
GRAPH_BACKEND—arcadedb(default) orfalkordbEMBEDDING_BACKEND—onnx(default) ortorch(optional extra)EMBEDDING_MODEL— defaultintfloat/multilingual-e5-baseRELATION_POLICY_ENFORCE—off|warn(default) |enforceRELATION_ALLOWED_TYPES— comma-separated edge type allowlistOWNER_SCOPE—client(default),header, ortoken. See Owners.JOBS_ENABLED—falseby default; enable for archive and dedup background jobs
See env.example for all options. Details: configuration.
Owners
The same server has three ways to choose owner_id. client stays the default, so a one-minute install does not change.
| Mode | Who chooses the owner | When to use it |
|---|---|---|
client |
The tool argument owner_id |
Trusted local or company deploy. Anyone who can call the server can name any owner. |
header |
X-Owner-Id, set by your gateway. The tool argument is ignored. The gateway sends Authorization: Bearer $ADMIN_TOKEN when that token is set. |
The server listens on 127.0.0.1. The gateway is the only client. |
token |
The API key's projects. owner_id must be one of them; omit it to use the first. |
A small team on one process. No second service. |
An API key is a person's name plus the owner graphs (projects) they may use. Team setup: docs/team-setup.md.
Token mode:
uv run graph-memory-mcp keys create --name alice --projects p1,p2
The command prints the key once. keys.json keeps the sha256 hash, name, and projects. Then:
OWNER_SCOPE=token
API_KEYS_FILE=./keys.json
A missing, unknown, or revoked key is HTTP 401. An owner_id that is not in the key's projects is HTTP 403 (or a tool error with memory_forbidden). graph-memory-mcp keys set-projects <id> p1,p3 replaces the list. keys list prints id, name, projects, and created time, never the key. keys revoke <id> revokes by id. In token mode every write stores the key's name as created_by / updated_by. In client and header those fields come from metadata.created_by / metadata.updated_by. Jobs keep the owner they were started with.
graph-memory-mcp --auth is the token entry point (server_auth.py). It forces OWNER_SCOPE=token and refuses to start without ADMIN_TOKEN. Tool schemas omit owner_id; the server uses the key's first project. Issue keys with keys create, inspect them with keys list, and revoke with keys revoke. The default server is unchanged: set OWNER_SCOPE=token there when the agent must pass owner_id to choose among a key's projects.
Token mode does not start without ADMIN_TOKEN and a readable keys file. /admin takes the owner from the path, so in this mode it always requires that token. client and header still leave /admin open when ADMIN_TOKEN is empty.
Header mode belongs on 127.0.0.1, with the gateway as the only client. The gateway sends Authorization: Bearer $ADMIN_TOKEN and X-Owner-Id. /mcp returns HTTP 401 when that token is set and the bearer is missing or wrong. Without ADMIN_TOKEN, the process starts on 127.0.0.1, ::1, or localhost only. Any other bind address exits. ALLOW_INSECURE_HEADER=1 logs a warning and starts anyway.
client mode has no /mcp authentication, so it starts on loopback only. A bind such as 0.0.0.0 exits unless ALLOW_INSECURE_CLIENT=1 is set (it logs a warning). For network access use OWNER_SCOPE=token.
/internal/graph-size in client and header requires ADMIN_TOKEN plus X-Owner-Id. token requires the API key; X-Owner-Id must be one of the key's projects, or the first project is used. /metrics stays open in every mode: handler, cache, and job counters, plus totals summed across owners. It does not name an owner.
Running the databases
ArcadeDB
cp env.example .env
docker compose up -d arcadedb
ArcadeDB runs on port 2480. Default credentials: root / playwithdata. Minimum version: 26.10 (the compose file pins 26.10.2). 25.10.1 fails with Index type 'LSM_VECTOR' is not supported.
Test with ArcadeDB:
GRAPH_BACKEND=arcadedb uv run pytest -m arcade
FalkorDB
To use FalkorDB instead of ArcadeDB, set GRAPH_BACKEND=falkordb in .env. Runs on port 6379, web UI at http://localhost:3000. Default password: falkordb123.
docker compose up -d falkordb
Test with FalkorDB:
GRAPH_BACKEND=falkordb uv run pytest -m "not arcade" -q
Test with ArcadeDB (default):
GRAPH_BACKEND=arcadedb uv run pytest -m arcade -q
Vector indexes
The server uses two vector indexes (Fact and Entity). The first write for an owner creates both, and later writes reuse that per-owner cache. They are also created on search, find_similar, or Fact auto_link when missing, or when AUTO_CREATE_INDEXES=true is set.
Changing the embedding model invalidates the stored corpus. You must re-embed every node after changing EMBEDDING_MODEL, its dimension, or EMBEDDING_QUERY_PREFIX / EMBEDDING_PASSAGE_PREFIX. See Embeddings.
Server profiles
Profiles with the same tools; agents pick one and stay on it:
| Profile | Start | Provenance |
|---|---|---|
| Default | graph-memory-mcp |
Nested source object |
| Simple | graph-memory-mcp --simple |
Flat (ref, provenance_type, source_updated_at, …); upsert_node requires ref |
| Extended | graph-memory-mcp --extended |
Same as default, plus description, shared_with_ids, collection_id, and node types Collection, User, Project |
| Auth | graph-memory-mcp --auth |
Same as default. OWNER_SCOPE=token. Schemas omit owner_id; the server uses the key's first project. |
See docs/features.md for details.
Graph Explorer
Local read-only web UI. Open a memory: enter a memory name and click Load, then search under Ask a question or open an item from the summary. An empty name shows "Enter a memory name, then click Load." and does not open default. More tools covers open by ID, find a path, similar wording, and how far to look. The toolbar is Fit, Arrange, Physics, zoom, text preview, Names, and Details. The map legend shows facts, entities, outdated items, contradicts, follows-from, and related. Details lead with the text, status, and validity. Timestamps, the id, and raw fields are under More about this item. Node text on the map is On hover by default, or Always (one short line). Always-mode labels hide when the map is zoomed out. The choice is stored in localStorage as gm_node_preview. The summary counts outdated facts. With --auth, the memory name shows the key's project and cannot be edited.
The shots below are readme_demo from assets/demo/seed.py and the dense demo owner demo_team. Regenerate them with node scripts/explorer_screenshots.mjs while Explorer is running.
# Terminal 1 — MCP server
uv run graph-memory-mcp --host 127.0.0.1 --port 8000
# Terminal 2 — Explorer
uv run graph-memory-explorer --host 127.0.0.1 --port 8088
Open http://127.0.0.1:8088. Against graph-memory-mcp --auth:
GRAPH_MEMORY_API_KEY=<key> uv run graph-memory-explorer --auth --host 127.0.0.1 --port 8088
The owner field is then bound by the key. --token is the same as the env var.
Multi-agent usage
Use owner_id to isolate or share memory:
- Shared team memory: both
agent_codegenandagent_debuguseowner_id="team_platform"and share all facts - Isolated per-agent:
agent_codegenusesowner_id="agent_codegen",agent_docsusesowner_id="agent_docs"; neither sees the other's memory
Each owner_id lives in its own graph (FalkorDB) or database (ArcadeDB). Queries do not see other owners.
Owner ID charset: both backends accept [A-Za-z0-9_@-]+. ARCADE_DATABASE must match ^[A-Za-z][A-Za-z0-9_]*$. Each ArcadeDB owner is an unquoted database {ARCADE_DATABASE}_{owner_id}.
See docs/memory_policies_for_LLM.md for agent-facing guidance.
Persistence and backups
ArcadeDB
Data is the Docker volume arcade-data (the image user cannot write a root-owned ./data-arcade bind). docker compose down keeps it; docker compose down -v deletes it. ArcadeDB admin endpoints support export/import:
# Export a database (requires authentication)
curl -u root:playwithdata http://localhost:2480/api/v1/export/memory_demo > backup.jsonl.gz
# Import (see ArcadeDB docs)
curl -u root:playwithdata -X POST http://localhost:2480/api/v1/import/memory_demo -F file=@backup.jsonl.gz
FalkorDB
docker-compose.yml enables AOF persistence (--appendonly yes --appendfsync everysec). A container crash loses at most one second of writes.
Point-in-time backups:
./scripts/backup.sh
This runs BGSAVE and copies dump.rdb to ./backups (keeps last 14).
Logical per-owner backups (both backends)
Portable across instances:
curl http://127.0.0.1:8000/admin/export/{owner_id}
Supports ?limit=&offset=§ion=nodes|relations for large graphs. Restore with POST /admin/import.
ArcadeDB backups: Data persists in the arcade-data volume. For a point-in-time copy, stop ArcadeDB and archive that volume. Logical export/import via the admin API works across backends.
Development
Tests
Default tests require FalkorDB running:
./scripts/test.sh
# or: docker compose up -d && uv run pytest -q
ArcadeDB tests:
uv run pytest -m arcade
Headless Explorer smoke (FalkorDB only). Skipped unless EXPLORER_BROWSER=1. Needs Explorer at EXPLORER_URL (default http://127.0.0.1:8088). The auth check uses EXPLORER_AUTH_URL (default http://127.0.0.1:8091) for an Explorer started with --auth. Playwright loads from PLAYWRIGHT_MODULE or /tmp/node_modules/playwright/index.mjs. CI excludes the marker (not arcade and not browser, and arcade and not browser).
EXPLORER_BROWSER=1 uv run pytest -q -m browser
Pre-commit hooks
Hooks run via the project venv:
uv run pre-commit run --all-files
uv run pre-commit install # optional git hooks
Status and roadmap
Stable
These features are tested and in active use:
- Core memory operations (
create_node,search,mark_outdated, relations,get_context,get_brief) - Physical per-owner isolation (ArcadeDB databases, FalkorDB graphs)
- Write-time deduplication (embeddings, threshold 0.92)
- Vector search with hybrid ranking (cosine + identifier match + recency)
- Server profiles (default,
--simple,--extended,--auth) - TTL facts excluded from default search
- MCP over Streamable HTTP
- Compact recall with token budget
Experimental
- Background jobs (deduplication, archival) when
JOBS_ENABLED=true - Versioning snapshots (
get_node as_of, requiresversioning=trueon writes) - Fact auto-linking (
auto_link=trueaddsMENTIONSto similar entities)
See docs/roadmap.md for planned features and CHANGELOG.md for recent changes.
Contributing
See CONTRIBUTING.md for development setup, testing guidelines, and pull request process.
License
MIT — see LICENSE