MCP server · tree-sitter · SQLite

Cartograph

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.

View on GitHub no embeddings no API keys $0 to run

Generated from a live index of this project · v0.1.0 · 2026-08-17

The problem

Give a coding agent a large unfamiliar repo and watch it work: grep, read a file, grep again. It burns context rebuilding structure a parser could have supplied in one call — and still misses the caller three modules away that its change just broke.

The reflex is RAG: embed the code, 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.

Live numbers from this repo

41
files indexed
7,360
lines parsed
526
symbols
1,587
graph edges
66%
internal resolution
3
languages
192
tests
0
API keys needed

Impact analysis, before the edit

The query that earns the whole project. One call names the dependent files and the tests to run.

$ 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)

  • tests/test_cli.py · d1 · test
  • tests/test_incremental.py · d1 · test
  • tests/test_resolver.py · d1 · test
  • src/cartograph/graph/resolver.py · d1
  • src/cartograph/indexer/pipeline.py · d1
  • src/cartograph/service.py · d1
  • tests/test_mcp.py · d2 · test
  • tests/test_traversal.py · d2 · test
  • tests/test_views.py · d2 · test
  • scripts/bench.py · d2
  • scripts/build_docs.py · d2
  • scripts/smoke_mcp.py · d2
  • src/cartograph/cli.py · d2
  • src/cartograph/mcp_server/server.py · d2
  • src/cartograph/views.py · d2
  • tests/conftest.py · d2
  • tests/test_docs.py · d3 · test

Affected symbols

  • cartograph.graph.resolver:resolve_imports · src/cartograph/graph/resolver.py:93 · 0.45 (name-only)
  • cartograph.graph.resolver:resolve_references · src/cartograph/graph/resolver.py:121 · 0.45 (name-only)
  • cartograph.indexer.pipeline:index_repo · src/cartograph/indexer/pipeline.py:42 · 0.45 (name-only)
  • cartograph.service:Cartograph._one · src/cartograph/service.py:118 · 0.45 (name-only)
  • cartograph.service:Cartograph.blast_radius · src/cartograph/service.py:200 · 0.45 (name-only)
  • cartograph.service:Cartograph.candidates · src/cartograph/service.py:124 · 0.45 (name-only)
  • cartograph.service:Cartograph.find_symbol · src/cartograph/service.py:105 · 0.45 (name-only)
  • cartograph.service:Cartograph.load · src/cartograph/service.py:84 · 0.45 (name-only)
  • cartograph.service:Cartograph.stats · src/cartograph/service.py:293 · 0.45 (name-only)
  • cartograph.service:Cartograph.symbol_detail · src/cartograph/service.py:127 · 0.45 (name-only)
  • cartograph.service:Cartograph.who_calls · src/cartograph/service.py:155 · 0.45 (name-only)
  • scripts.bench:benchmark · scripts/bench.py:62 · 0.45 (name-only)
  • tests.test_cli:test_opening_an_index_writes_nothing · tests/test_cli.py:201 · 0.45 (name-only)
  • tests.test_cli:test_reading_while_indexing_does_not_lock · tests/test_cli.py:185 · 0.45 (name-only)
  • tests.test_cli:test_schema_is_not_recreated_on_reopen · tests/test_cli.py:220 · 0.45 (name-only)
  • tests.test_incremental:test_fts_rows_do_not_leak_after_reindex · tests/test_incremental.py:189 · 0.45 (name-only)
  • tests.test_resolver:test_deleting_a_target_removes_its_edges · tests/test_resolver.py:334 · 0.45 (name-only)
  • tests.test_traversal:test_find_symbol_exact_flag_excludes_partial_matches · tests/test_traversal.py:42 · 0.45 (name-only)
  • cartograph.cli:search_cmd · src/cartograph/cli.py:175 · 0.40 (ambiguous)
  • cartograph.mcp_server.server:_reset · src/cartograph/mcp_server/server.py:71 · 0.40 (ambiguous)
  • cartograph.mcp_server.server:search_code · src/cartograph/mcp_server/server.py:131 · 0.40 (ambiguous)
  • cartograph.service:Cartograph.related · src/cartograph/service.py:181 · 0.40 (ambiguous)
  • tests.test_incremental:edge_names · tests/test_incremental.py:19 · 0.40 (ambiguous)
  • tests.test_incremental:qualnames · tests/test_incremental.py:28 · 0.40 (ambiguous)
  • tests.test_incremental:test_a_single_edit_still_reresolves_globally · tests/test_incremental.py:273 · 0.40 (ambiguous)

Recall-first view (min_confidence 0.3): treat as a review checklist, not a proof of impact.

Architecture, extracted not documented

Module graph, layering, cycles and hotspots — computed from imports and the call graph. Edge labels are import counts; PageRank picks out the symbols that carry the codebase.

flowchart LR
  m0["cartograph.indexer.extract"] -- 5 --> m1["cartograph.models"]
  m2["cartograph.mcp_server.server"] -- 5 --> m3["cartograph.service"]
  m4["cartograph.cli"] -- 4 --> m3["cartograph.service"]
  m5["cartograph.views"] -- 4 --> m3["cartograph.service"]
  m6["cartograph.graph.resolver"] -- 3 --> m7["cartograph.graph.store"]
  m7["cartograph.graph.store"] -- 3 --> m1["cartograph.models"]
  m3["cartograph.service"] -- 3 --> m8["cartograph.graph.algorithms"]
  m9["scripts.build_docs"] -- 3 --> m3["cartograph.service"]
  m4["cartograph.cli"] -- 2 --> m10["cartograph.indexer.pipeline"]
  m6["cartograph.graph.resolver"] -- 2 --> m11["cartograph.indexer.languages"]
  m0["cartograph.indexer.extract"] -- 2 --> m11["cartograph.indexer.languages"]
  m10["cartograph.indexer.pipeline"] -- 2 --> m7["cartograph.graph.store"]
  m10["cartograph.indexer.pipeline"] -- 2 --> m1["cartograph.models"]
  m3["cartograph.service"] -- 2 --> m7["cartograph.graph.store"]
  m3["cartograph.service"] -- 2 --> m1["cartograph.models"]
  m5["cartograph.views"] -- 2 --> m1["cartograph.models"]
  m4["cartograph.cli"] --> m12["cartograph"]
  m4["cartograph.cli"] --> m11["cartograph.indexer.languages"]
  %% showing 18 of 35 module edges by weight

Hotspots — highest PageRank

SymbolLocationFan-in Rank
cartograph.views:TokenBudget.addsrc/cartograph/views.py:41920.0243
cartograph.indexer.pipeline:index_reposrc/cartograph/indexer/pipeline.py:42460.0237
cartograph.models:Symbolsrc/cartograph/models.py:8420.0227
cartograph.graph.store:_to_symbolsrc/cartograph/graph/store.py:65990.0213
tests.test_extract:parsetests/test_extract.py:14170.0131
tests.test_mcp:calltests/test_mcp.py:43150.0129
cartograph.service:Cartograph.loadsrc/cartograph/service.py:84100.0110
cartograph.indexer.languages:adapter_for_pathsrc/cartograph/indexer/languages.py:42550.0092
cartograph.graph.store:GraphStore.closesrc/cartograph/graph/store.py:124120.0092
cartograph.service:Cartograph.closesrc/cartograph/service.py:94120.0092

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 every edge records the rule that produced it and a confidence, and callers choose their own operating point: who_calls is precision-first (≥0.5), blast_radius is recall-first (≥0.3).

RuleConfidenceIntuition Edges here
same-file0.95the definition is right there in scope440
import0.90the file explicitly imported this name184
receiver-type0.85Foo.bar() where Foo is a known container0
same-module0.75sibling file in the same package2
unique-global0.60one repo symbol has this name, bare call site5
name-only0.45one match, but on an untyped receiver139
ambiguous0.40*N candidates, kept as N edges at 1/N each145
external0.40*rooted at a third-party or stdlib import197
unresolved0.00genuinely unknown (dynamic, or a typed method)475

The 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 was unique — and surfacing as a confident caller. A method name on a receiver you cannot type is not evidence, so it now sits below the precision line.

The tool surface

Ten MCP tools, two resources and a prompt. Descriptions are written as routing guidance, because the description is the prompt the model uses to choose.

find_symbol

Where is X defined? Ranked by structural importance.

search_code

Full-text over names, signatures and 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.

Benchmarks

Real repositories, single process, M-series laptop. Cold = full index; warm = no-op reindex.

RepoFilesKLOC SymbolsEdgesCold WarmInternal resolution
django2,97353445,394252,44111.9s0.67s83.2%
gin (Go)98241,6109,1790.32s0.03s88.1%
flask83181,6244,2710.21s0.03s87.4%

Warm reindex is fast because resolution is skipped only when both its inputs are provably unchanged. On Django that took a no-op run from 7.5s to 0.67s with a byte-identical graph. Reproduce with scripts/bench.py --clone.

Try it

uv tool install cartograph-mcp

cartograph index ~/code/my-repo
cartograph arch
cartograph blast src/auth/token.py

# wire it into an agent
claude mcp add cartograph -- cartograph serve ~/code/my-repo

Limitations

Stated plainly, because a code-intelligence tool that oversells its precision is worse than useless.