Skip to main content
Glama
Aditya31398

repo-atlas-mcp

by Aditya31398

repo-atlas-mcp

An MCP server that gives coding agents a graph of your codebase instead of a text search over it.

Grep finds a name. It cannot tell you who calls that function, what breaks if you change it, or whether any test reaches it. Those are relationship questions, so repo-atlas-mcp parses your repository with tree-sitter, loads the call and import structure into Neo4j, and exposes it to your agent as MCP tools.

▸ hotspots
  17 callers  stock_analyzer/base.py:54        is_num
  10 callers  stock_analyzer/data/market.py:144  get_row
   9 callers  stock_analyzer/base.py:12        Finding

▸ impact_of is_num
  21 production caller(s) across 11 file(s).
  ⚠ No test reaches this symbol on any call path — changes here are unguarded.

Why a graph

Reachability is transitive, and transitive questions are exactly what a graph database answers in one query and a text search cannot answer at all. impact_of walks every call path upstream of a symbol, partitions the callers into production code and tests, and tells you when nothing guards the change. That is the query that stops an agent from confidently editing shared code.

Requirements

  • Node.js 18+

  • A Neo4j instance. AuraDB Free is enough for repositories in the hundreds-of-thousands-of-lines range and costs nothing.

Setup

Create a free AuraDB instance, then note the connection URI and password it gives you.

Add the server to your MCP client:

{
  "mcpServers": {
    "repo-atlas": {
      "command": "npx",
      "args": ["-y", "repo-atlas-mcp"],
      "env": {
        "NEO4J_URI": "neo4j+s://xxxxxxxx.databases.neo4j.io",
        "NEO4J_USER": "neo4j",
        "NEO4J_PASSWORD": "your-password"
      }
    }
  }
}

For Claude Code that goes in .mcp.json at your project root, or run:

claude mcp add repo-atlas -- npx -y repo-atlas-mcp

Then ask your agent to index the project once:

index this repo at /path/to/project

Re-indexing is incremental — unchanged files are skipped by content hash, so running it again after a few edits takes about as long as parsing the files you touched.

Tools

Tool

Question it answers

index_repo

Build or refresh the graph for a repository

find_symbol

Where is this function/class/method defined?

who_calls

What reaches this symbol, transitively?

what_it_calls

What does this symbol depend on, transitively?

impact_of

What breaks if I change this — and do any tests cover it?

path_between

How does control flow from A to B?

hotspots

Which symbols have the most distinct callers?

module_map

What does the top-level package structure depend on?

list_repos

What is indexed?

drop_repo

Remove a repository from the graph

Languages

Python, TypeScript, TSX, and JavaScript, via tree-sitter WASM grammars — no native compilation, so npx works on a clean machine without build tools.

How call resolution works, and where it is wrong

Resolving a call to a definition properly requires full type inference. This does something cheaper and tells you when it is guessing. What the call looks like decides how it is resolved:

self.foo() / this.foo() — resolves to foo on the enclosing class, else to a definition in the same file. This is the one method form that resolves precisely. Tier: local.

foo() — a bare call, very likely a repo-level function. Resolved against a definition in the same file (local), then in a file this one imports (imported), then repo-wide if three or fewer candidates exist (global).

something.foo() — the receiver's type is unknown. Accepted only when foo is defined exactly once in the whole repository and is not a well-known builtin name. Tier: receiver.

That last rule matters more than it looks. Without it, one repo method named get absorbs every dict.get() and response.get() in the codebase; on a real 26-file project it produced a phantom symbol with 59 callers, ranked as the most-called code in the project. The denylist in COMMON_METHOD_NAMES (get, add, update, join, map, read, …) is a heuristic, and it is consulted only for this weakest case — a bare get() still resolves normally.

Every CALLS edge stores which tier produced it, and the tools surface anything above local.

Known limitations, stated plainly:

  • Dynamic dispatch is invisible. getattr(obj, name)(), reflection, and dependency-injection wiring produce no edges.

  • Polymorphism is not modelled. Two classes with a run() method cannot be told apart, so calls on a non-self receiver to a name defined more than once are dropped rather than guessed.

  • Calls into third-party packages are dropped. Only symbols defined inside the repository become nodes.

  • Top-level code is attributed to a synthetic <module> symbol per file, so route registration and CLI wiring still appear in the graph rather than being discarded.

  • Repeated calls between the same pair collapse to one edge, which keeps the last line number seen.

The practical effect: the graph under-reports rather than over-reports. impact_of can miss a caller that goes through dynamic dispatch, so treat its output as a strong lead, not a proof — but a symbol it names really is a caller.

Graph model

(:Repo)-[:HAS_FILE]->(:File)-[:DEFINES]->(:Symbol)
(:File)-[:IMPORTS]->(:File)
(:Symbol)-[:CALLS {line, confidence}]->(:Symbol)

Symbol.isTest is set from the defining file's path, which is what lets impact_of separate covering tests from production callers.

Ranking in hotspots uses caller in-degree rather than PageRank, so it runs on AuraDB Free, which does not include the Graph Data Science library.

Development

npm install
npm run build
node scripts/smoke.mjs                 # parser check, no database needed
node scripts/bulk-parse.mjs /some/repo  # parse a real repo and report totals

License

MIT