图记忆MCP服务

Graph Memory MCP 是一个自托管的 MCP 服务器,为团队与 AI 代理提供基于图结构的持久化记忆管理,具备插入去重、过期标记、矛盾检测和按 owner 物理隔离等能力,适用于跨会话编程代理、团队架构决策和多代理共享记忆场景。

  • by river-ai-lab
  • Python
  • 混合部署
  • 更新于
在 GitHub 查看 river-ai-lab/graph-memory-mcp

使用说明

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 facts FOLLOWS_FROM old ones
  • Explicit outdating: mark_outdated hides superseded facts; CONTRADICTS edges surface conflicts in contradiction_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_id gets 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_duplicate or possible_duplicates returned, do not write a third paraphrase
  • Fact became wrong → mark_outdated(fact_id, reason, owner_id="team_backend") + create_node(new_text), not update_node
  • After meaningful work (GROW): Ground what changed → Record it → Orient (if recurring, add tags/relations) → Write with metadata.project and created_by
  • Temporary notes get ttl_days (14-30); durable decisions have no TTL
  • Link facts with create_relation using the default allowlist: RELATED_TO, MENTIONS, SUMMARIZES, FOLLOWS_FROM, CONTRADICTS, EXTRACTED_FROM, SIMILAR_TO, NEXT_STEP, EXPOSES, CROSS_REF, RUNS_ON, USES, SERVED_BY (see relation_policy.py; default mode: warn). auto_link writes MENTIONS

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).
  • search has no as_of parameter yet: Only get_node supports 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-base are about 1.1 GB. Quantized weights are not used. EMBEDDING_BACKEND=torch needs uv 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 CONTRADICTS edges explicitly; they are surfaced in search results but not inferred semantically by the server.
  • Owner access is OWNER_SCOPE: client (default) uses the tool argument owner_id. header ignores it and reads X-Owner-Id (loopback bind; the gateway sends Authorization: Bearer $ADMIN_TOKEN when that token is set). token requires a Bearer API key; owner_id must 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_node with deduplication (embeddings-based refusal at threshold 0.92, no LLM call)
  • search with hybrid ranking (cosine + identifier match + recency), compact=true for token-budgeted snippets
  • get_brief for task warmup (project anchor, recent durable facts, open contradictions, stale count)
  • mark_outdated / CONTRADICTS edges / fact versioning with optional snapshots
  • get_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=true adds MENTIONS to 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) or falkordb
  • EMBEDDING_BACKEND — onnx (default) or torch (optional extra)
  • EMBEDDING_MODEL — default intfloat/multilingual-e5-base
  • RELATION_POLICY_ENFORCE — off | warn (default) | enforce
  • RELATION_ALLOWED_TYPES — comma-separated edge type allowlist
  • OWNER_SCOPE — client (default), header, or token. See Owners.
  • JOBS_ENABLED — false by 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_codegen and agent_debug use owner_id="team_platform" and share all facts
  • Isolated per-agent: agent_codegen uses owner_id="agent_codegen", agent_docs uses owner_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=&section=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, requires versioning=true on writes)
  • Fact auto-linking (auto_link=true adds MENTIONS to 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