Cartograph
Cartograph is an MCP server that provides agent-native code intelligence by building a queryable code graph from a repository using tree-sitter and SQLite. It offers tools for symbol lookup, call graph traversal, blast radius analysis, and architecture overview to help coding agents answer structural questions about code.
README
Cartograph
Agent-native code intelligence. Turn any repository into a queryable code graph and serve it to coding agents over MCP — so an agent can ask "what breaks if I change this?" instead of grepping and hoping.
tree-sitter + SQLite. No embeddings, no vector store, no API keys, no server, no cost.
→ Live demo — generated from a real index of this repo on every push.
The problem
Give a coding agent a large unfamiliar repo and watch what it does: grep, read a file, grep again, read another file. It burns context reconstructing structure that a parser could have told it in one call — and it still misses the caller three modules away that its change just broke.
The usual fix is RAG: embed the codebase, retrieve "similar" chunks. But "who calls this function?" is not a similarity question. It has an exact answer, and that answer lives in the call graph.
Cartograph builds the graph, then hands agents ten tools shaped for how they actually work.
$ cartograph blast src/cartograph/graph/store.py
## Blast radius — file `src/cartograph/graph/store.py`
17 dependent file(s), 31 affected symbol(s), 7 test file(s).
**Tests to run first**
- `tests/test_cli.py`
- `tests/test_docs.py`
- `tests/test_incremental.py`
- `tests/test_mcp.py`
- `tests/test_resolver.py`
- `tests/test_traversal.py`
- `tests/test_views.py`
**Dependent files** (by import distance)
- `src/cartograph/graph/resolver.py` · d1
- `src/cartograph/indexer/pipeline.py` · d1
- `src/cartograph/service.py` · d1
- `src/cartograph/cli.py` · d2
…
One call, before the edit. Not seven greps after the test suite goes red.
Quickstart
uv tool install cartograph-mcp # or: pipx install cartograph-mcp
cartograph index ~/code/my-repo # builds .cartograph/cartograph.db
cartograph arch # modules, layers, cycles, hotspots
cartograph blast src/auth/token.py # what a change here could break
cartograph callers validate_token # reverse call tree
Wire it into an agent
Claude Code:
claude mcp add cartograph -- cartograph serve /path/to/repo
Or any MCP client, via mcp.json:
{
"mcpServers": {
"cartograph": {
"command": "cartograph",
"args": ["serve", "/path/to/repo"]
}
}
}
serve indexes on first run if no index exists. Then ask your agent "what would break if I changed the token validator?" and it will call blast_radius instead of guessing.
The ten tools
| Tool | Answers |
|---|---|
find_symbol |
Where is X defined? (ranked by structural importance) |
search_code |
Full-text over names, signatures, docstrings (BM25) |
get_symbol |
One symbol: signature, doc, members, callers, callees, source |
who_calls |
Reverse call tree — before you change a signature |
what_it_calls |
Forward call tree — understand code without reading every file |
blast_radius |
What a change could break, and which tests to run |
related_symbols |
"What else should I read?" via personalized PageRank |
file_summary |
What a file defines, imports, and who imports it |
architecture_overview |
Modules, layering, import cycles, hotspots, entry points |
index_stats |
Index health and the edge-resolution breakdown by rule |
Plus MCP resources (cartograph://architecture, cartograph://stats) and an orient prompt for a graph-first first pass at an unfamiliar repo.
Languages: Python, TypeScript, TSX, JavaScript, Go.
Design decisions worth arguing about
1. Confidence is a first-class column
Without a type checker you cannot know that store.who_calls() means GraphStore.who_calls. You can only rank hypotheses. So rather than pretending, every edge records the rule that produced it and a confidence:
| Rule | Confidence | Intuition |
|---|---|---|
same-file |
0.95 | the definition is right there in scope |
import |
0.90 | the file explicitly imported this name |
receiver-type |
0.85 | Foo.bar() where Foo is a known container |
same-module |
0.75 | sibling file in the same package |
unique-global |
0.60 | exactly one repo symbol has this name, bare call |
name-only |
0.45 | one match, but on an untyped receiver |
ambiguous |
≤0.40 | N candidates, kept as N edges at 1/N each |
external |
0.00 | rooted at a third-party/stdlib import |
unresolved |
0.00 | genuinely unknown (dynamic, or a typed method) |
Callers then choose their own operating point. who_calls defaults to ≥0.5 — precision first, because an agent acts on the answer. blast_radius drops to 0.3 — recall first, because a missed impacted test is the expensive mistake and a false positive only costs a reviewer a glance.
That name-only tier exists because of a real bug. seen.add(...) on a builtin set was resolving to a repo class's add method, purely because the name happened to be unique — and it showed up as a confident caller. A method name on a receiver you cannot type is not evidence, so it now lands below the precision line. (test)
external exists for honesty about metrics: on most repos the "unresolved" bucket is dominated by typer.Option and sqlite3.execute. Lumping those in makes coverage look far worse than it is, so Cartograph reports internal resolution — of the call sites that could hit a repo symbol, how many did.
2. Parsing is incremental; resolution never is
A file is reparsed only when its sha256 moves. But raw references are stored as facts in a refs table, and edges is recomputed as a pure function of (refs × symbols) whenever anything changed.
This is what makes "reindex after every edit" trustworthy. If resolution were also incremental, editing one file could leave an edge in another file pointing at a symbol that had moved. Global re-resolution makes that structurally impossible. (test)
The cost is real, so there is exactly one safe shortcut: if no file was added, reparsed, or removed, both input tables are unchanged and resolution is provably identical — so it is skipped. That took a no-op reindex of Django from 7.5s to 0.67s with a byte-identical graph.
3. PageRank instead of embeddings
"Which get did you mean?" is a structural question. The get that forty call sites depend on is the one the agent wants, and the call graph already knows that. So symbol ranking is weighted PageRank over the call graph — stable, explainable, and free. No model, no index build, no vector store.
related_symbols extends the same idea: personalized PageRank seeded on one symbol, treating the graph as undirected, because when you are about to change a function both its callers and its callees are relevant context. It is the structural analogue of semantic search, and it needs no embeddings.
4. Tools return Markdown, not JSON, under a token budget
The consumer is a context window. A 40-symbol JSON array spends thousands of tokens on braces and repeated keys, and the model reformats it anyway. Every view here is compact Markdown with a hard token budget.
Critically, every truncation is announced. An agent handed 20 of 87 callers with no marker will confidently conclude the other 67 do not exist, and then delete something.
5. Traversal runs in SQLite, not Python
who_calls at depth 4 is a recursive CTE, so the whole traversal stays inside SQLite's C loop. On Django's 252k-edge graph that is ~5ms. Pulling the edge table into Python to walk it would not be.
Benchmarks
Real repositories, M-series laptop, single process. Cold = full index from scratch; warm = no-op reindex.
| Repo | Files | KLOC | Symbols | Edges | Cold | Warm | DB | Internal resolution |
|---|---|---|---|---|---|---|---|---|
| django | 2,973 | 534 | 45,394 | 252,441 | 11.9s | 0.67s | 80 MB | 83.2% |
| gin (Go) | 98 | 24 | 1,610 | 9,179 | 0.32s | 0.03s | 2.5 MB | 88.1% |
| flask | 83 | 18 | 1,624 | 4,271 | 0.21s | 0.03s | 1.7 MB | 87.4% |
Query latency (median of 5, warm):
| Repo | find_symbol |
who_calls d3 |
blast_radius |
architecture_overview |
|---|---|---|---|---|
| django | 12.3ms | 5.1ms | 5.6ms | 68.5ms |
| gin | 0.4ms | 0.4ms | 0.5ms | 1.2ms |
| flask | 0.5ms | 1.1ms | 1.3ms | 1.8ms |
Reproduce with scripts/bench.py.
Architecture
flowchart LR
subgraph index["cartograph index"]
W[walker<br/>git ls-files] --> P[tree-sitter<br/>+ .scm queries]
P --> X[extract<br/>defs · refs · imports]
end
X --> DB[(SQLite<br/>symbols · refs<br/>edges · FTS5)]
DB --> R[resolver<br/>rule cascade]
R --> DB
DB --> RK[PageRank<br/>Tarjan SCC]
RK --> DB
DB --> S[service facade]
S --> V[views<br/>token-budgeted MD]
V --> M[MCP server<br/>10 tools]
V --> C[CLI]
M --> A((coding agent))
| Module | Responsibility |
|---|---|
indexer/walker.py |
File discovery — defers to git ls-files for correct .gitignore semantics |
indexer/languages.py |
One adapter per language: extensions, queries, docstrings, module keys, import resolution |
indexer/extract.py |
AST → symbols/references/imports, language-agnostic |
queries/*.scm |
tree-sitter capture patterns — the per-language knowledge, as data |
graph/schema.sql |
The graph: files, symbols, refs, edges, imports, FTS5 |
graph/resolver.py |
The confidence cascade |
graph/algorithms.py |
PageRank, personalized PageRank, iterative Tarjan SCC, layering |
graph/store.py |
Recursive-CTE traversal, ranked lookup, aggregates |
service.py |
One facade so the CLI and MCP server cannot drift |
views.py |
Token-budgeted Markdown |
Scoping without combinatorial queries
The trick that keeps queries/*.scm small: scope is never encoded in the query. Every captured definition is indexed by its tree-sitter node id, and a reference's enclosing symbol is found by walking its parent chain until it hits one. That is O(tree depth) per reference and handles closures, methods, inner classes, and arrow functions for free — no per-shape patterns.
Adding a language
Subclass LanguageAdapter (~40 lines) and drop in a .scm file. GoAdapter is the shortest complete example. tests/test_queries.py then automatically compiles your queries against the grammar and asserts they capture something.
Development
git clone https://github.com/GokulRaj2210/cartograph-mcp && cd cartograph-mcp
uv sync
uv run pytest -q # 209 tests
uv run ruff check .
uv run mypy # strict
CI runs the suite on Python 3.11/3.12/3.13 (plus macOS), then dogfoods: it indexes this repo, fails on import cycles, asserts a no-op reindex reparses nothing, and drives the MCP server over real stdio. It also installs the built wheel into a clean venv and indexes with it, because packaged .scm files are easy to leave out of a wheel and impossible to notice locally.
The cycle gate has already earned its keep — it caught a store → resolver → store cycle that I introduced in this repo, which was fixed by moving the offending helper rather than by relaxing the gate.
Notable tests
tests/test_queries.py— every.scmcompiles against every grammar that loads it, and captures something. A pattern valid in JavaScript ((class_heritage (identifier))) is an Impossible pattern in TypeScript, which wraps supertypes inextends_clause. That one line silently produced zero TypeScript symbols.tests/test_incremental.py— no stale edges after edits, deletions, or a symbol moving between files.tests/test_resolver.py— every rule fires, and none over-claims its confidence.tests/test_cli.py— a reader and an indexer can hold the database at once.tests/test_docs.py— the generated demo page is well-formed HTML with balanced tags, which is how the Markdown renderer's crossed-tag bug onmin_confidencewas caught.
Limitations
Stated plainly, because a code-intelligence tool that oversells its precision is worse than useless:
- No type inference.
self.conn.execute(...)cannot be resolved to a repo symbol without knowingconn's type. Those land inunresolved, and they are the bulk of what remains at ~85% internal resolution. - Dynamic dispatch is invisible.
getattr(obj, name)(), decorator registries, and DI containers do not appear as edges. - Cross-language edges are not tracked. A TypeScript frontend calling a Python endpoint is two disconnected subgraphs.
- Definitions only, not every reference. A symbol used as a value (passed as a callback) is weaker in the graph than one that is called.
Roadmap: Rust and Java adapters, optional LSP enrichment for exact resolution where a language server is available, and a --changed-since <ref> mode for PR-scoped blast radius.
Why this exists
I wanted to know whether a coding agent's biggest weakness on large repos — no structural model of the code — could be fixed with static analysis and a well-shaped tool surface rather than a bigger model or a vector database. Mostly, it can.
License
MIT
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。