graph-mcp
Provides a knowledge-graph index over a markdown corpus in Neo4j, enabling semantic search, tag navigation, document similarity, and entity relationship discovery.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@graph-mcpfind notes related to distributed systems"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
graph-mcp
A Neo4j knowledge-graph index over a personal Knowledge/ markdown corpus,
exposed to LLM agents over MCP. Companion to
knowledge-mcp: that server owns
the files, this one owns navigation.
The graph is an index, never a second copy of the corpus. Nodes carry
paths, titles and hashes; agents traverse here and then read real content
through knowledge-mcp's read_knowledge. The only stored text is chunk
text, which has to exist to be embedded and returned as a search snippet.
Architecture
Knowledge/*.md ──> graph-sync ──> Neo4j (Bolt, private interface)
│ ▲
│ │
└─> embedding model (OpenAI-compatible endpoint)
│
LLM agent ──MCP──> graph-mcp ───────────┘The three pieces can live on one machine or three. The reference deployment
runs Neo4j on a home server and graph-sync/graph-mcp on a workstation
alongside a locally-served embedding model.
Two MCP servers, deliberately split: connection profiles differ (Neo4j pooling vs plain file I/O), the graph can be added or removed independently, and scoped tool descriptions keep the model routing to the right one.
Schema
Node | Key | Notes |
|
| Relative to the KB root. Also |
|
|
|
|
|
|
|
| One node per unique frontmatter tag. |
Edge | Meaning |
| Resolved |
| Frontmatter tag. |
| Vector index membership. |
| Stage 3. |
| Stage 3. |
| From a |
Related MCP server: 2BToRePensieve
Tools
Always available:
Tool | Purpose |
| Meaning-based retrieval over chunks. Use when wording won't match; use |
| Tag navigation. |
| Discover the corpus's tag vocabulary. |
| One note's neighbourhood: tags, links in/out, entities. |
| Related notes by embedding, beyond hand-written links. |
| Node/edge counts — check which stages have run. |
Registered only when GRAPH_MCP_SEMANTIC_TOOLS=1 (after Stage 3 populates the
edges they traverse):
Tool | Purpose |
| "What connects to X", "who worked on Y". |
| How two entities are connected. |
| "What's changed lately about X". |
Setup
Requires uv, a Neo4j 5.x instance, and an OpenAI-compatible embeddings endpoint.
git clone https://github.com/cao-jacky/graph-mcp
cd graph-mcp
uv sync
cp .env.example .env # set GRAPH_MCP_KB_ROOT and NEO4J_PASSWORDRegister with Claude Code:
claude mcp add --scope user graph \
--env GRAPH_MCP_KB_ROOT=/path/to/your/Knowledge \
--env NEO4J_URI=bolt://127.0.0.1:7687 \
--env NEO4J_PASSWORD=... \
-- uv run --directory /path/to/graph-mcp graph-mcpServing over HTTP
Desktop MCP clients spawn graph-mcp as a local stdio process and need
nothing here. HTTP is for clients that cannot spawn a local process — an
agent running in another container or on another host.
A container running the stdio entrypoint has nothing attached to its stdin and
will simply block, so the image sets GRAPH_MCP_TRANSPORT=streamable-http.
export GRAPH_MCP_AUTH_TOKEN=$(openssl rand -hex 32)
export KB_ROOT=/path/to/your/Knowledge
export EMBED_BASE_URL=http://<host-reachable-from-the-container>:1234/v1
docker compose --profile server up -dGRAPH_MCP_AUTH_TOKEN is required for HTTP — the server refuses to start
without it rather than serving the corpus unauthenticated. Every request must
carry Authorization: Bearer <token>; anything else gets a 401.
Note EMBED_BASE_URL must be reachable from inside the container.
127.0.0.1 refers to the container itself, so unless the embedding model runs
there too, use the host's LAN/VPN address.
Env var | Default | Purpose |
|
|
|
|
| Listen address and mount path |
| — | Required bearer token for HTTP |
| unset | Comma-separated |
On GRAPH_MCP_ALLOWED_HOSTS: the SDK can reject unrecognised Host headers to
block DNS rebinding, but its allowlist matches exactly or on a host:*
port pattern — * alone is not a wildcard and would reject everything. DNS
rebinding is a browser attack, MCP clients are not browsers, and the bearer
token already gates every request, so the check is disabled unless you set an
allowlist.
Registering with Hermes Agent
Hermes has two unrelated extension surfaces, and this is the MCP one, not a
native plugin: a repo without plugin.yaml/__init__.py is a valid MCP server
but not a Hermes plugin, and hermes plugins install will say so. Add to
~/.hermes/config.yaml:
mcp_servers:
graph:
url: "http://graph-mcp:8000/mcp" # or http://<host>:8000/mcp
headers:
Authorization: "Bearer ${GRAPH_MCP_AUTH_TOKEN}"${VAR} resolves from ~/.hermes/.env, so put the token there and keep it out
of the config file and out of any notes directory that syncs to a git remote.
Tools surface to the agent prefixed by server name — mcp_graph_semantic_search,
mcp_graph_documents_by_tag, and so on.
The networks must be shared
[Errno -2] Name or service not known means exactly this and nothing else:
Docker's embedded DNS resolves service names only within a shared
user-defined network. An agent deployed as its own stack is on its own
network, so graph-mcp is not a resolvable name there — and the two could not
reach each other by IP either.
Join the client's network from this side, so the client's container is never modified or recreated:
# find it — this is the DOCKER network name, project-prefixed. It is not the
# key used in the client's compose file: `networks: {hermes-net: ...}` under
# project `hermes` becomes `hermes_hermes-net`.
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' hermes
# then, for this stack — BOTH variables are needed
AGENT_NETWORK=hermes_hermes-net AGENT_NETWORK_EXTERNAL=true \
docker compose --profile server up -dAGENT_NETWORK_EXTERNAL=true says "join this, don't create it". With only
AGENT_NETWORK set, Compose would try to create a network of that name and
the client would not be on it.
docker network connect hermes_hermes-net graph-mcp does the same thing
immediately, but is lost when the container is recreated; the variables
survive redeploys.
Once shared, the client reaches http://graph-mcp:8000/mcp over that network.
No host port is published, deliberately: it would only invite collisions
(port 8000 is a popular default) without being needed.
If a client on another host needs it, add an override file and bind it to a
private interface — never 0.0.0.0, which would expose corpus snippets to
every network the host can reach:
# docker-compose.publish.yml
services:
graph-mcp:
ports:
- "10.0.0.5:8000:8000" # a VPN/LAN addressdocker compose -f docker-compose.yml -f docker-compose.publish.yml \
--profile server up -dBuild plan and validation gates
Each stage has a gate. Don't start the next stage until the current one's check passes — the two risky points are Stage 3 (extraction quality and dedup) and Stage 5 (automating before the pipeline is trustworthy).
Stage 0 — Neo4j
echo "NEO4J_PASSWORD=$(openssl rand -base64 24)" > .env
docker compose up -d
docker compose ps # healthy
docker compose exec neo4j cypher-shell -u neo4j -p "$NEO4J_PASSWORD" "RETURN 1"Portainer: Stacks → Add stack → Repository, point it at this repo. The
root docker-compose.yml is the stack file; set NEO4J_PASSWORD (and
BOLT_BIND_ADDR, if needed) in Portainer's environment-variables editor.
Bolt publishes on 127.0.0.1 by default, which is correct when graph-sync
and graph-mcp run on the same host as Neo4j. If they run elsewhere, set
BOLT_BIND_ADDR to a private address — a VPN/WireGuard/Tailscale address
or a LAN address. Never 0.0.0.0, which would expose Bolt to every network
the host can reach.
Gate: container healthy, RETURN 1 succeeds, and uv run graph-sync status reports counts rather than a connection error.
Rollback: docker compose down -v neo4j — its own volume, no blast radius
on the rest of the stack.
Stage 1 — Structural extraction
uv run graph-sync parse-check # offline, no Neo4j needed
uv run graph-sync schema
uv run graph-sync structuralGate: document count matches the file count
(find "$GRAPH_MCP_KB_ROOT" -name '*.md' -not -path '*/.*' | grep -v '/index.md' | wc -l),
and notes you know cross-reference each other show LINKS_TO edges.
Rollback: idempotent — fix the script and re-run rather than hand-cleaning. Re-running also prunes documents, edges and orphan tags that no longer exist, so renames and deletions self-correct.
Stage 1b — Vector index
uv run graph-sync embed # ~7 min for 1408 chunks; --force to redo allNot in the original plan; added because the local embedding model makes semantic retrieval and reliable entity dedup free. Skips unchanged documents by content hash.
Gate: graph_overview shows embedded_chunks == chunks, and
semantic_search on a topic you know returns the right note in the top few.
Stage 2 — Minimal graph-mcp
Register the server (above) and leave GRAPH_MCP_SEMANTIC_TOOLS unset.
Gate: ask an agent a tag-navigation question whose answer you know ("what notes are tagged hermes") and confirm the tool is called and returns the right set — before trusting it with anything semantic.
Stage 3 — Semantic extraction
uv run graph-sync semantic --limit 8 # validate on notes you know first
uv run graph-sync semantic # then the full backfillRuns against the local LM Studio model by default: no API cost, and no note
content leaves the machine. Entity dedup happens before insert — exact key
match, then Qwen3 embedding similarity above
GRAPH_MCP_ENTITY_MERGE_THRESHOLD (0.92) within the same entity type.
Gate: inspect the extraction for 5–10 notes you know well before running
at scale. Watch for systematic misses — bullet-heavy notes tend to yield fewer
relations than prose. Check find_related_entities on a familiar entity for
wrongly-merged or wrongly-split entities; tune the threshold and re-run rather
than cleaning up afterwards.
Rollback: additive and idempotent per document.
MATCH ()-[r:RELATES_TO]->() DELETE r;
MATCH (e:Entity) DETACH DELETE e;
MATCH (d:Document) REMOVE d.extracted_hash;Stage 1 data is untouched by this.
Stage 4 — Relationship tools
Set GRAPH_MCP_SEMANTIC_TOOLS=1 and restart the MCP client.
Gate: ask a genuinely multi-hop question you don't already know the answer to and check the returned path is sane. This is the first point where the graph does something plain search could not.
Stage 5 — Automation
Only after Stages 1–4 have been run by hand enough times to trust their behaviour on renames, deletions and malformed frontmatter.
cp deploy/graph-sync.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now graph-sync.timerThe timer runs structural + embed only; the semantic pass stays manual.
Gate: edit one note, wait for the trigger, confirm the graph updated without a manual run. Keep the manual path working as an escape hatch.
Configuration
Env var | Default | Purpose |
| falls back to | Corpus root. Must exist. |
|
| Bolt endpoint. |
|
| Credentials. |
|
| LM Studio OpenAI-compatible endpoint. |
|
| Embedding model. |
|
| Must match the model and the vector index. |
|
| Texts per embedding request. |
| LM Studio / | Stage 3 extraction. |
|
| Chunk sizing. |
|
| Cosine similarity above which two entities merge. |
| unset |
|
Corpus quirks the parser handles
Discovered by running against the real 277-note corpus; the tests in
tests/test_parse.py pin each one:
Most notes have no frontmatter. 163 of 277 (the imported
ai-systems/tree). Absent frontmatter is the common case, not an error; dates fall back to file mtime.Nested subdirectories.
ai-systems/08-memory-and-state/*.mdsits two levels deep. The walk is fully recursive.Wikilinks inside code must not become edges. A
grep "^[[:space:]]*$"snippet and prose about`[[wikilinks]]`would otherwise create bogus nodes. Fenced blocks and inline code are blanked before extraction.The synced
*-SKILL.mdnotes have malformed fencing — a bare```preview block containing further```fences — so by CommonMark their shell snippets are not* code. A plausibility filter rejects targets containing:or `while keeping real names likeCyberpunk 2077`.All 56 skill notes open with
# SKILL.md. A heading that is merely a filename is rejected in favour of thesynced_from:directory name.Tags come in both inline (
[a, b]) and block (- a) YAML form.Short sections are packed together. One chunk per heading gave 4024 chunks averaging 90 words; packing yields 1408 averaging 258.
Troubleshooting
Everything here was hit during a real deployment, in this order.
Could not perform discovery. No routing servers available
You are connecting with the neo4j:// scheme, which performs cluster routing
discovery that a single instance does not offer. Use bolt:// — in the
Browser's connect dialog, in NEO4J_URI, everywhere. neo4j:// is only for
clusters and Aura.
AuthError after setting NEO4J_USER, or an unknown-database error
Neo4j Community Edition has exactly one user and one database, both named
neo4j. CREATE USER and CREATE DATABASE are Enterprise features, and
SHOW DATABASES returns only neo4j and system. The compose file's
NEO4J_AUTH creates neo4j/<password>; leave NEO4J_USER and
NEO4J_DATABASE at their defaults.
If you want an isolated graph, run a second container with its own volume — that is the Community-edition equivalent of a second database.
Bolt works but the Browser doesn't (or vice versa)
BOLT_BIND_ADDR and BROWSER_BIND_ADDR are independent, and default to
127.0.0.1 separately. Publishing one does not publish the other.
This matters for SSH tunnels: -L 7687:127.0.0.1:7687 resolves 127.0.0.1
on the server, so it only works if that port is published on the server's
loopback. If you set BOLT_BIND_ADDR=10.0.0.5, the tunnel must target that
address:
ssh -L 7474:127.0.0.1:7474 -L 7687:10.0.0.5:7687 user@serverOr skip the tunnel for Bolt and point the Browser straight at
bolt://10.0.0.5:7687, which is what graph-sync uses anyway.
The server restarts mid-embed, or Bolt writes fail with ServiceUnavailable
The container is being OOM-killed, and restart: unless-stopped brings it
back — so the port looks healthy afterwards and the cause is easy to miss.
Confirm it:
CALL dbms.queryJmx('java.lang:type=Runtime') YIELD attributes
RETURN attributes.Uptime.value / 60000 AS uptime_minutesAn uptime far shorter than the container's age is the tell. Check heap too — if heap use is low, the JVM is fine and it is the container limit being hit, not the heap.
NEO4J_MEM_LIMIT must cover heap + pagecache + JVM overhead (metaspace,
direct buffers, thread stacks) and Lucene's off-heap allocations, which
are substantial when building 4096-dim vector indexes. The 4g default with a
2G heap and 1G pagecache is marginal for a full embedding pass; 8g is a
safer floor for vector workloads. memswap_limit equals mem_limit by
design, so there is no swap cushion — the limit has to be genuinely
sufficient.
The embed stage watermarks each document as its last chunk lands, so a run
killed this way keeps completed documents; just re-run it.
Tests
uv run python tests/test_parse.py # 23 checks, no Neo4j or network neededKnown gap in knowledge-mcp
knowledge-mcp's _entry_files() walks only the root and one level of
category directories, so the ~135 notes nested deeper (ai-systems/*/*.md)
are invisible to search_knowledge, list_knowledge and the generated
index.md. graph-mcp indexes them, which means semantic_search can return
a path that read_knowledge will still happily read but that
search_knowledge would never have found. Worth fixing there separately.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityBmaintenanceLocal-first agentic knowledge layer over Obsidian notes, enabling MCP-aware agents to search, retrieve, and compile knowledge with provenance and task contracts.Last updated3792MIT
- Flicense-qualityDmaintenanceBuilds a persistent knowledge graph from notes and conversations, enabling semantic search, entity exploration, and GTD task management from any MCP-compatible AI assistant.Last updated3
- Alicense-qualityBmaintenanceExposes a personal markdown-based second brain (Obsidian-style) as an MCP server, enabling agents to search, read, and write notes with privacy controls.Last updatedMIT
- Flicense-qualityBmaintenanceEnables semantic search over personal markdown notes by indexing them into a vector database and exposing search, reindex, and status tools via MCP.Last updated
Related MCP Connectors
Serve a folder of Markdown notes as an MCP server: hybrid search, reading, and sourced answers.
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
Search, read, and write your Apple Notes from ChatGPT/Claude via a local Mac agent + MCP relay.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/cao-jacky/graph-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server