graphlore
A Python MCP server that exposes the Graphify knowledge graph as MCP tools, prompts and resources β so an AI assistant can explore your codebase through the graph during development, cheaply (token-budgeted) and structurally.
Note: Graphify ships its own embedded MCP server (
graphify ./raw --mcp). This project adds analysis tools, token-budgeted subgraph extraction, git freshness checks, per-community resources, reusable prompts, and LLM-friendly tool annotations + structured (JSON) output on top.
Why graphify_locate
One MCP call turns a natural-language question into a navigational map, not a wall of code:
- π Semantic + structural, one call β semble finds the relevant code, the graph gives its neighborhood. ~235 tokens to orient vs ~61k for grep+read (263Γ fewer on httpx).
- π
hidden_linksβ semantically similar code that is structurally disconnected (duplication / missing-abstraction / sync-async-twin candidates) that neither search nor the graph surfaces alone. - π Multi-language, zero config β Python via stdlib
ast; JS/TS Β· Go Β· Java Β· Rust Β· C++ Β· 165+ more via tree-sitter with automatic language detection. Span-join precision 70β96% on real HTTP-client repos in six languages (benchmark). - π Cosmetic-aware freshness β
graphify_freshnessignores comment/format-only edits (in every language) so a reformat never triggers a needless rebuild.
One call beats running semble and graphify separately
semble finds what's relevant; graphify gives how it connects. They're complementary β but stitching them by hand means four calls, ~2.7k tokens, and manually aligning semble's line ranges to graph nodes. graphlore does that join for you, in one call:
| per query | semble alone | graphify alone | both, by hand | graphify_locate |
|---|---|---|---|---|
| Semantic search | β | β | β | β |
| Graph structure | β | β | β | β |
| Chunk β symbol join | β | β | you wire it | β automatic |
hidden_links cross-check |
β | β | β | β only here |
| Calls | 1 | 1 | 4 | 1 |
| Tokens to orient | 1,613 | 1,107 | 2,721 | 235 |
β 11.6Γ fewer tokens than running the two separately β in a single call, and hidden_links (semantically similar code that is structurally disconnected) is a signal neither tool produces alone. So the combined tool isn't just convenience: it's cheaper, and it surfaces something the parts can't. (full benchmark β)
Installation
# graphlore itself
pip install graphlore
# plus the Graphify CLI it wraps (needed for build/query/path/explain/add)
pip install graphifyy && graphify install
From source:
git clone https://github.com/yasinyaman/graphlore
cd graphlore
pip install -e ".[dev]"
Running
GRAPHIFY_PROJECT_DIR=/path/to/repo graphlore
# equivalently:
GRAPHIFY_PROJECT_DIR=/path/to/repo python -m graphlore
Renamed from
graphify-mcp: the old name collided with thegraphify-mcpconsole script thatgraphifyyships for its embeddedserver, which forced the clunkygraphify-mcp-serverentry point. Asgraphlorethe bare command is ours. The boot banner on stderr(graphlore vX.Y.Z | transport=β¦ | project=β¦) confirms which serverand project dir you're actually running.
Claude Code
Copy mcp.json to a .mcp.json at your project root. GRAPHIFY_PROJECT_DIR: "." uses the project root.
Claude Desktop / Cowork
Add the contents of claude_desktop_config.json to your Claude Desktop config:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Transport (stdio default, optional HTTP)
stdio is the default and the right choice for a per-developer local server. Toserve over HTTP instead (e.g. a shared graph for a team or a web MCP client):
GRAPHIFY_TRANSPORT=streamable-http GRAPHIFY_HOST=127.0.0.1 GRAPHIFY_PORT=8000 \
GRAPHIFY_PROJECT_DIR=/path/to/repo graphlore
Any HTTP transport force-enables path containment (GRAPHIFY_RESTRICT_PATHS)so a network client can't drive graphify_build to extract arbitrary filesystempaths. HTTP binds 127.0.0.1 by default. To expose it beyond localhost, setGRAPHIFY_API_KEY β every request must then send Authorization: Bearer <key>(constant-time checked, 401 otherwise); binding a non-loopback host without a keyprints a warning.
When bound to a loopback host, the MCP SDK auto-enables DNS-rebindingprotection: only Host: 127.0.0.1 / localhost / ::1 requests are accepted. Areverse proxy in front (nginx/caddy on a public name forwarding to127.0.0.1) must therefore rewrite the Host header β or setGRAPHIFY_ALLOWED_HOSTS to the public name(s) (comma-separated, :* portwildcards allowed; * disables the protection for a trusted proxy).
The CLI is always invoked as an argument list with no shell (subprocess.runwith shell=False), so a build path or query string can't inject shell commands.For a shared/network deployment, also consider lowering GRAPHIFY_TIMEOUT (default600s) so a single slow graphify_build can't tie up a worker for ten minutes.
GRAPHIFY_TRANSPORT=streamable-http GRAPHIFY_HOST=0.0.0.0 GRAPHIFY_API_KEY=$(openssl rand -hex 16) \
GRAPHIFY_PROJECT_DIR=/path/to/repo graphlore
For a smaller tool surface (helps some models pick the right tool), setGRAPHIFY_TOOLSET=lean to expose only the core exploration tools.
Environment variables
| Variable | Default | Description |
|---|---|---|
GRAPHIFY_PROJECT_DIR |
. |
Project root to extract the graph from |
GRAPHIFY_OUT_DIR |
graphify-out |
Output folder name |
GRAPHIFY_BIN |
graphify |
CLI path |
GRAPHIFY_TIMEOUT |
600 |
CLI timeout (seconds) |
GRAPHIFY_RESTRICT_PATHS |
0 |
Confine graphify_build's path to the project dir (auto-on for HTTP) |
GRAPHIFY_TRANSPORT |
stdio |
stdio | streamable-http | sse |
GRAPHIFY_HOST |
127.0.0.1 |
Bind host for HTTP transports |
GRAPHIFY_PORT |
8000 |
Bind port for HTTP transports |
GRAPHIFY_API_KEY |
(unset) | Require Authorization: Bearer <key> on HTTP transports |
GRAPHIFY_ALLOWED_HOSTS |
(unset) | DNS-rebinding Host allowlist for HTTP (comma-separated, :* port wildcards; * disables). Unset = SDK default: loopback-only when bound to loopback |
GRAPHIFY_TOOLSET |
full |
full | lean (core exploration tools only) |
GRAPHIFY_TOKENIZER |
(heuristic) | tiktoken β exact token counts (needs the [tiktoken] extra); else chars/3.5 estimate |
GRAPHIFY_SEMANTIC_BACKEND |
semble |
Semantic backend for locate/duplication_scan. semble (offline default) or a module.path:Factory implementing the SemanticIndex protocol (search/find_related; results expose .chunk.file_path/.start_line/.end_line) β plug in local sentence-transformers, an OpenAI-compatible / on-prem vLLM endpoint, etc. |
GRAPHIFY_WATCH |
0 |
1 β watch the project for structural source changes and re-sync the graph automatically (needs the [watch] extra; cosmetic edits are ignored) |
GRAPHIFY_WATCH_DEBOUNCE |
2.0 |
Seconds to coalesce a burst of file events before re-graphing (watch mode) |
Keeping the graph fresh
The analysis tools surface staleness for you: graphify_overview andgraphify_subgraph carry a lightweight graph_age ("built 3 commits ago"), andgraphify_freshness gives a full recommended_action (fresh / update / rebuild).To stop thinking about it, regenerate on every commit with a git post-commithook β the recommended first-class auto-update flow:
# .git/hooks/post-commit (then: chmod +x .git/hooks/post-commit)
#!/bin/sh
# incremental, viz-free, backgrounded so the commit returns immediately
graphify . --update --no-viz >/dev/null 2>&1 &
Incremental --update only re-extracts changed files β it can't drop nodes fordeleted/renamed code on its own. graphify_prune closes that gap: it surgicallyremoves the phantom nodes (and their edges) for source files that are gone from theworking tree, so after a delete/rename you can graphify_prune (preview withdry_run=True) + graphify_build(update=True) instead of a full rebuild.graphify_freshness knows about this β it only steers to a rebuild while phantomnodes for the removed files still linger, and reports them in phantom_files. Anagent can also just call graphify_build(update=True) when graph_age /graphify_freshness says the graph drifted.
Tools
CLI-backed (the first two write state; the rest are read-only):
| Tool | Purpose |
|---|---|
graphify_build |
Build/update the graph (--update, --cluster-only, --mode deep) |
graphify_add |
Add a source by URL (arXiv, tweet) |
graphify_query |
Natural-language query (--dfs, --budget) |
graphify_path |
Exact path between two nodes |
graphify_explain |
Everything about a node |
graph.json analysis (read-only, no CLI needed, as_json=True for structured output):
| Tool | Purpose |
|---|---|
graphify_overview |
Call first β size, god nodes, communities, surprises, suggested next steps |
graphify_god_nodes |
Most connected nodes |
graphify_communities |
Leiden community summaries |
graphify_surprises |
Unexpected cross-domain connections |
graphify_search |
Node search |
graphify_neighbors |
1-hop neighbors of a node |
graphify_subgraph |
Token-budgeted BFS subgraph around a node β the cheap way to feed the model just the relevant slice |
graphify_impact |
Reverse-dependency / blast radius β what breaks if a node changes (direction=dependents/dependencies/both), ordered by hop distance |
graphify_node_details |
Node metadata: type, source file/line, docstring, community |
graphify_skeleton |
def/class signatures (decorators kept, bodies stripped) for a file/node/community β the middle layer between the map and full code |
graphify_fetch |
Token-budgeted source hydration β reads the real code for a node (its enclosing def/class span Β± context), the mapβcode other half of subgraph/locate |
graphify_freshness |
Is the graph stale vs. git HEAD? Returns recommended_action (fresh/update/rebuild) + reason β lingering phantom nodes / large changes steer to a rebuild |
graphify_diff |
Structural changeset between two git refs (default HEAD~1..HEAD) β added/removed/renamed/modified, with cosmetic-only changes separated (file-level, for review/audit) |
graphify_prune |
Drop phantom nodes (and their edges) for deleted/renamed source files β the surgical alternative to a full rebuild (dry_run=True to preview) |
graphify_validate |
Lint the graph for dangling/duplicate/self-loop edges and orphan nodes (read-only) |
graphify_duplication_scan |
Repo-wide hidden-link / duplication audit β the batch form of locate's hidden_links (similar-but-structurally-far pairs); needs [semble], outside lean |
graphify_cycles |
Circular dependencies β strongly-connected node groups in the directed graph (an architectural smell), self-loops listed separately |
graphify_package_apis |
Symbol-level external API surface β which names each external package is actually used for (fastapi: Depends, APIRouter), with qualified paths (numpy.linalg.norm) for version-diff audits; a lower bound (dynamic/star/getattr use is invisible). Python via stdlib ast; JS/TS, Go, Java need [treesitter] |
Semantic naming (uses the host model via MCP sampling β no API key β or a backend key):
| Tool | Purpose |
|---|---|
graphify_sampling_status |
Capability test: reports whether the client supports host-LLM sampling, whether a backend key is set, and which method will be used |
graphify_label_communities |
Give Leiden communities human-readable names. method="auto" (sampling β key β placeholder), "sampling", "cli", or "placeholder" |
graphify_set_labels |
Persist assistant-provided community names (sampling-free fallback) to .graphify_labels.json and patch them into graph.html |
Semantic bridge (optional [semble] extra β semantic search joined to graph structure):
| Tool | Purpose |
|---|---|
graphify_locate |
NL query β enclosing graph node β token-budgeted subgraph, plus hidden_links: semantically-similar code that is structurally disconnected (duplication / missing-abstraction candidates) |
Naming communities without an API key (MCP sampling)
The Leiden clustering is keyless, but turning Community 7 into Authenticationneeds a model. Three ways, in graphify_label_communities's preference order:
- Host-LLM sampling β the server asks the connected client to run thecompletion via MCP
sampling/createMessage. The model the user already uses(e.g. Claude in a sampling-capable client) does the naming; the server holdsno API key. Subject to client support β callgraphify_sampling_statusfirst; it degrades gracefully when unsupported. All communities are named ina single batched request, carried over whichever transport the negotiatedprotocol allows (the legacy back-channel, or input-required rounds on MCP2026-07-28+), so it works with both older and modern clients. - Backend API key (
method="cli") β setGEMINI_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY/ β¦ (or run a local ollama) and graphify's ownbackend names them. This option always remains available. - Placeholders β no model anywhere: names stay
Community N.
If the client can't sample and you have no backend (e.g. Claude Code, whichdoesn't support sampling), use the assistant-driven fallback: the assistantis already a capable model in the loop, so it reads graphify_communities andpushes names back via graphify_set_labels({"0": "Authentication", ...}) βno key, no sampling, works in any client. The names persist to.graphify_labels.json and are patched into graph.html.
Semantic bridge (optional [semble])
pip install "graphlore[semble]" adds graphify_locate, which joinssemble's semantic code search to the graphin one call. Graphify gives structure (how code connects); semble givesretrieval (which code is semantically relevant) β they're complementary.
graphify_locate("how does retry backoff work"):
- semble finds the most relevant code and resolves the top hit to its enclosinggraph node (better than label matching).
- returns the token-budgeted subgraph around it (structure).
- runs semble
find_relatedand cross-checks: a cousin that is semanticallysimilar but not within the seed's structural neighborhood is flagged as ahidden_link(with its hop distance) β a duplication / missing-abstraction /implicit-coupling candidate that neither tool surfaces alone.
The extra is optional: without it the core tools are unchanged and graphify_locatereturns an install hint. It also pairs well with running semble's own MCP serveralongside graphlore.
The chunkβnode join and the freshness cosmetic-vs-structural check workacross languages: Python uses the stdlib ast (no extra deps), and everyother language (JS/TS, Go, Rust, Java, Ruby, C/C++, β¦) is handled by an optionaltree-sitter backend β pip install "graphlore[treesitter]", also pulled inby graphify. Without it, non-Python files fall back to nearest-line matching.
Benchmark
Averaged over 6 queries spanning httpx subsystems (send path, digest auth,redirects, content decoding, cookies, timeouts) on the 2,101-node graph. Each queryorients an agent to a code area; tokens = what reaches the model's context(β chars/4).
| Approach | Tokens (avg) | Calls | Structure | Semantic | Hidden links |
|---|---|---|---|---|---|
| Naive grep + read | 61,836 | ~14 | β | β | 0 |
| semble alone | 1,613 | 1 | β | β | 0 |
| graphify alone | 1,107 | 1 | β | β | 0 |
| semble + graphify (separately) | 2,721 | 4 | β | β | 0 |
graphify_locate |
235 | 1 | β | β | 7 |
graphify_locate averages 263Γ fewer tokens than grep+read and 11.6Γ fewerthan running semble and graphify separately (one call instead of four) β and it'sthe only approach that surfaces hidden_links (semantically similar but structurallydisconnected code), 5β10 per query.
Those ~235 tokens are a navigational map (seed file:line + structuralneighborhood + hidden links), not raw code β you fetch the specific code only whereneeded. That's the trade graphlore optimizes: cheapest orientation plus thecross-check signal, then drill in precisely.
Case study β the hidden links are real. Asked "does httpx duplicaterequest-sending across sync and async?", graphify_locate returned the seedClient._send_single_request and flagged hidden links. Checking the sourceconfirmed every production flag is a genuine sync/async twin:Client._send_single_request (_client.py:1001) β AsyncClient._send_single_request(:1717); BaseTransport.handle_request β handle_async_request (in everytransport); __enter__ β __aenter__. ~500 tokens (one locate + a targeted read)surfaced a real architectural pattern that naively reading _client.py (~16k tokens)would. The unreachable bucket also held test files (related, not refactor targets) βthe distance field separates production parallels (3β4) from that noise.
Across languages β real HTTP-client repos. The span join and freshness check aren'tPython-only. I built AST-only graphs for an HTTP client in five more languages and ran thesame kind of queries (send Β· redirects Β· timeout/retry Β· headers/auth Β· transport):
| Language | Repo | Span-join precision | Qualname | Hidden / q | locate vs grep |
|---|---|---|---|---|---|
| Python (ast) | encode/httpx |
96% (52/54) | 67% | 3.2 | 272Γ |
| JavaScript / TS | sindresorhus/got |
89% (48/54) | 67% | 2.3 | 583Γ |
| Go | go-resty/resty |
93% (50/54) | 100% | 1.8 | 911Γ |
| Java | square/retrofit |
85% (46/54) | 50% | 2.3 | 217Γ |
| Rust | algesten/ureq |
70% (38/54) | 83% | 3.7 | 577Γ |
| C++ | libcpr/cpr |
72% (39/54) | 100% | 4.3 | 195Γ |
Python uses the stdlib ast; JS/TS Β· Go Β· Java Β· Rust Β· C++ go through tree-sitter withautomatic language detection β one tool, zero per-language config. Span-join precision =share of semantic hits landing inside the resolved symbol's real span (any overload of it βC++ collapses same-name overloads into one graph node while each keeps its own span; cpr'sSession::SetOption has 46). It's 70β96% across six 350β2,095-node graphs, hidden-linkskeep surfacing 2β4/query, and locate stays 195β911Γ cheaper than grep+read. Rust and C++trail at 70β72% β their misses are mostly file-top/whole-file chunks and namespace-level freefunctions where the resolution is still correct (they recover qualified names at 83β100%).graphify_freshness's cosmetic-vs-structural check is correct in every language too(comment/reformat β cosmetic; operator/rename β structural). Re-measured 2026-08 on the MCP v2SDK, Python 3.14, fresh repo HEADs. Reproduce withbenchmarks/multilang.py.
β Full benchmark report (interactive HTML, per-query breakdown + the cross-language tables) β or open docs/benchmark.html locally. (TΓΌrkΓ§e)
Measured 2026-06 with semble 0.3.4 + graphify (tree-sitter backend). httpx headline = 6queries (per-query locate 189β286 tokens); cross-language = 6 queries Γ 54 hits each ongot / resty / retrofit / ureq / cpr. Sample bias: every repo benchmarked here isan HTTP-client library β a deliberately uniform family chosen for cross-language comparability.Token savings and span-join precision will differ on other architectures (data pipelines, GUIapps, sprawling monorepos), so treat these as indicative, not guarantees. Numbers vary bycodebase and query.
Resources
graphify://reportβ GRAPH_REPORT.mdgraphify://graphβ graph.json (raw)graphify://community/{id}β per-community wiki (members + internal/boundary edges)
Prompts
Reusable templates that orchestrate the tools for the assistant:
onboardβ orient to the codebase (overview β communities β subgraphs β surprises β summary)trace_bug(symptom)β find likely root-cause locations through the graphexplain_flow(flow)β end-to-end walkthrough of a named flow with file:line refs
LLM-friendliness
- Tool annotations (
read_only_hint,destructive_hint, titles) tell the model which tools are safe to call freely vs. which mutate state. - Server instructions describe the recommended flow (overview β targeted subgraph/query β build update).
as_jsonoutput on every analysis tool returns structured data the model can chain on instead of re-parsing prose.- Token budgeting (
graphify_subgraph) keeps context small on large graphs β the core of Graphify's ~71Γ compression. - Host-LLM sampling (
graphify_label_communities) lets the server borrow the client's model via MCPsampling/createMessage, so semantic naming works with no server-side API key β with a capability test (graphify_sampling_status) and a backend-key fallback.
Typical workflow
graphify_overview()β orientationgraphify_communities()β subsystemsgraphify_subgraph("SomeNode")β token-cheap targeted explorationgraphify_query("how does the auth flow work?")β questions- After code changes:
graphify_freshness()βgraphify_build(".", update=True)
Project layout
graphlore/
βββ src/graphlore/ # package (server.py, __init__.py)
βββ tests/ # pytest suite + fixture graph.json
βββ .github/workflows/ # CI (ruff + pytest, py 3.10β3.12)
βββ pyproject.toml # packaging + console script
βββ mcp.json # Claude Code example config
βββ claude_desktop_config.json
Development
pip install -e ".[dev]"
ruff check .
pytest -q
See CONTRIBUTING.md. Licensed under MIT.