🌐 Epicenter
Name a symbol. See everything that shakes.
Epicenter parses a Python codebase into a dependency graph, then walks that graph backwards to answer the question developers actually ask before touching code:
"If I change this, what breaks?"
It finds indirect callers that text search cannot see, tells you which of them no test reaches, and exposes all of it over MCP so AI coding assistants can consult real structure instead of inferring it from grep.
Why this exists
AI coding assistants reason about repository structure by searching text and guessing. They miss indirect callers, inherited overrides, and cross-module chains. A parsed call graph doesn't — it knows that editing fetch_graph_data reaches CuriosityEngine.__init__ two hops away, and it can show the chain.
That precision is the product. The graph is the means.
🚀 Quick start
pip install epicenter-mcp
Index a codebase, then ask:
epicenter index /path/to/repo
epicenter impact save_user
With more than one codebase indexed, name which to query: --realm /path/to/repo.
Changing db.save_user could affect 5 symbol(s):
── directly ──
api.create_account (function, 3 dependents) [UNTESTED]
── 2 hops away ──
web.signup_handler (function, 0 dependents)
via web.signup_handler <- api.create_account <- db.save_user
1 of 5 affected symbol(s) have no test reaching them.
| Command | Answers |
|---|---|
impact <symbol> |
What breaks if this changes |
deps <symbol> |
What this relies on — the code to read first |
coverage <symbol> |
Which tests reach it |
coupling <symbol> |
What historically changes alongside it |
dead |
Symbols no entrypoint or test can reach |
find <query> |
Look up a symbol's qualified name |
index <path> |
Parse a codebase into the graph |
history [path] |
Mine git history for change coupling |
stats |
Graph size, entrypoints, tests, coverage, unresolved calls |
From Claude Code
Add this to .mcp.json in any project, restart Claude Code, and approve it:
{
"mcpServers": {
"epicenter": { "command": "epicenter-mcp" }
}
}
Then ask in plain language: "what breaks if I change fetch_graph_data?"
Tools: impact_of, test_coverage, change_coupling, dead_code, dependencies_of, find_symbol, index_codebase, list_codebases.
🏗️ Architecture
graph TD
Src[Python source tree] -->|two-pass AST walk| PL[Parser - pipeline]
PL -->|nodes, edges, roles| DB[(SQLite cache - database)]
Git[(git history)] -->|co-change - cochange| DB
DB --> IM[Dependency graph - graph]
IM --> Q[impact / coverage / dead / coupling]
Q --> MCP[MCP server - mcp_server]
Q --> CLI[epicenter CLI]
MCP -->|tools| AI[Claude Code / any MCP client]
DB --> EG[Path ranking - engine.py]
EG --> UI[Streamlit workbench - app.py]
| Module | Role |
|---|---|
epicenter/pipeline.py |
Two-pass AST parser. Symbols, call resolution, role detection. |
epicenter/database.py |
Realm-scoped SQLite cache. |
epicenter/graph.py |
Dependency graph and the impact/coverage/reachability queries. |
epicenter/cochange.py |
Change coupling mined from git history. |
epicenter/cli.py |
Command line interface. |
epicenter/mcp_server.py |
The queries as MCP tools. |
epicenter/engine.py |
Centrality-based path ranking (earlier direction, retained). |
app.py |
Streamlit workbench over engine.py. |
Everything ships inside the epicenter package. Its module names — database, pipeline, engine — are generic enough that installing them at the top level would shadow whatever else in a user's environment claims them.
Developing on it
python3 -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
.venv/bin/python -m pytest tests -q
🧠 How it works
Dependency edges vs structural edges
The central distinction. An edge A --calls--> B means A depends on B, so a change to B propagates back to A. An edge module --contains--> class means no such thing — containment describes where code lives, not what relies on what.
Keeping both in one graph makes reachability leak: you hop from a method up into its file, then back down into an unrelated method, until every symbol appears connected to every other. Epicenter keeps them in separate graphs, and only calls / inherits / imports carry a change forward.
Impact = reverse reachability
Multi-source breadth-first search over the reversed dependency graph. BFS (not DFS) so the recorded path is the shortest chain of blame. Querying a class also seeds from its methods, since changing a class means changing what it contains. Results rank by distance, then fan-in — a nearby symbol many things depend on is the more dangerous one.
Call resolution
The parser tracks variable types well enough to resolve the patterns that defeat string matching:
self.db = Database() # learned in __init__
self.db.save(row) # → store.Database.save
local = Database()
local.save(row) # → store.Database.save
def handler(db: Database):
db.save(row) # → store.Database.save
Types come from constructor calls, parameter annotations, and annotated assignments. Class bodies are pre-scanned before their methods are visited, because a method using self.db may be defined above the __init__ that creates it.
Roots, and what static analysis cannot see
Coverage and dead code both need to know where execution starts. Epicenter treats as roots: __main__ guards, module-level main/cli, tests, decorator-registered functions, and modules nothing imports but which have module-level code (Streamlit apps and scripts, which never write a __main__ guard).
Getting dead code from noisy to useful meant handling four ways a framework invokes code with no call edge to show for it:
| Invisible call | How it's handled |
|---|---|
ast.NodeVisitor dispatching to visit_Call |
A class with an external base may have overrides its base calls |
@app.route, @mcp.tool, @pytest.fixture |
Any non-inert decorator means registration. Enumerating inert decorators is tractable; enumerating every framework's registering ones is not |
@property read as an attribute |
Descriptor decorators marked implicit |
Foo() linking to Foo, never Foo.__init__ |
Reachability computed to a fixpoint, so a live class pulls in its dunders and whatever they call |
On this repository that took the dead list from 46 entries to 1 — and the survivor is real.
Change coupling
Structure is not the only kind of dependency. Two functions that always change in the same commit are coupled even when neither calls the other — a config key and the code reading it, an encoder and its decoder, a schema writer and its reader. No edge exists to find, so no amount of parsing will surface them. History will.
epicenter history walks recent commits, maps each diff hunk onto the symbols defined in that file at that commit (not today's layout, which would attribute changes to whatever happens to sit at those lines now), and counts what moves together. Each change is attributed to the innermost symbol covering it, so editing one method does not implicate its whole class.
Coupling is reported as confidence — of the commits touching this symbol, the share that also touched the other — and pairs with no code path between them are flagged, because those are the ones structure cannot tell you about. On this repository, save_code_graph_to_db and fetch_graph_data come out at 100% with nothing calling anything: the write and read halves of one schema.
It is correlation, not dependency. It needs real history to say anything — a handful of commits will pair things that merely travelled together.
Roots
Libraries need one more root. Their callers live outside the codebase entirely, so a package's __init__.py re-exports — its public surface — are treated as entrypoints. Without that, every public function in a library reads as dead.
⚠️ Known limitations
Resolution is heuristic, not a type checker. It is incomplete, and errors run toward under-reporting: a listed caller is reliable, but "nothing depends on this" is the answer to distrust.
Resolved: constructor assignments, parameter and return annotations, X | None unions, class-body annotations (dataclass, pydantic, attrs), self attributes and attributes read into locals, async methods and awaited calls, closures, class-qualified calls, callbacks and bound-method references, relative imports, and package re-exports.
Not resolved:
- Unannotated indirection —
get_connection().execute()where nothing declares a return type. AnAnyannotation carries no information either. - Containers —
handlers = [Foo()]thenhandlers[0].run() - Reassignment — last-write-wins, so a variable changing type mid-function records wrong
- Duck typing — same ceiling
- Runtime reflection —
getattr(self, f'{key}_schema')builds a method name from a string. Nothing static can follow it; pydantic's JSON-schema generator dispatches this way throughout. - Plugins loaded by path — a mypy or pytest plugin named in a config file has no caller in the codebase at all.
- A library's submodule API — public surface is read from
__init__.pyre-exports. A symbol users import straight from a submodule (from pydantic.v1.color import Color) is indistinguishable from dead code without reading the docs. Treating every public submodule symbol as API would suppress almost everything and make the dead-code report useless, so it is left as-is.
Two more things to hold loosely:
- Test coverage here means reachability, not assertion. A test that reaches a symbol may not check anything about it. It is a floor on confidence, not a measure of it.
- Dead code is a list of candidates, never a delete list. An unresolved caller makes live code look dead.
stats reports unresolved_calls — call sites the parser could not tie to any symbol. Those targets are typed unresolved rather than filed as external libraries, so the size of the blind spot is visible instead of hidden.
Python only.
Measured
| This repo | A 2,700-line app | mcp SDK | pydantic | networkx | |
|---|---|---|---|---|---|
| Files | 14 | 21 | 123 | 105 | 580 |
| Symbols | 171 | 126 | 1,407 | 2,300 | 8,337 |
| Dependency edges | 248 | 293 | 2,905 | 4,527 | 13,447 |
| Dead-code candidates | 1 | 3 | 42 | 229 | 7 |
| Index time | <0.1s | 0.3s | 0.7s | 1.1s | 3s |
Every one of those dead-code numbers started far higher. On the application it was 25, on networkx 581. Each round of checking the false positives by hand exposed a distinct gap — callbacks passed but never called, return annotations, relative imports, a package losing its own name, and same-module inheritance building its edge from the import map alone. Running it against code neither of us wrote found far more than self-analysis ever did.
Dynamic dispatch is handled rather than conceded. A method whose name is called on a receiver that could not be typed — registry[key](...).solve() — is treated as live, and so are subclass overrides of any live method, since calling a base method runs whichever override the instance carries. Neither invents a dependency edge: the call is real but its destination is genuinely unknown, and corrupting impact analysis to tidy up a different report would be the wrong trade.
The 7 that survive on networkx are backend-interface methods and test helpers reached by machinery no parser can follow.
🧪 Tests
pytest tests -q
74 tests covering call resolution, storage, impact queries, reachability, package layout, dynamic dispatch, and history mining. Nearly all are regressions for bugs found by running Epicenter against real code — the realm-collision data loss, closures collapsing into one node, relative imports never resolving, a package losing its own name, and each framework-dispatch false positive in the dead-code list.
📄 License
MIT — see LICENSE.
🛠️ Stack
Python 3.10+ · NetworkX · SQLite · ast · MCP SDK · Streamlit (workbench only)
🗺️ Roadmap
- Diff blast radius — impact analysis across a branch or PR rather than a single symbol.
- Context packing — the minimal token-budgeted set of definitions needed to modify a symbol.
- Real name resolution — delegate to
pyrightfor what heuristics cannot reach. - Subsystem detection — community detection on the module graph, rendered as Mermaid.
📜 Project history
This began as a Wikipedia "rabbit hole" explorer, became a codebase path-finder that ranked routes by centrality and "serendipity," and is now Epicenter.
The path-ranking framing didn't survive contact with the problem — its own worked example returned four suggestions with identical scores, three terminating in range, enumerate, and set. Serendipity is a recommender-systems metric: wandering is the point on Wikipedia, but nobody wants to wander their codebase. They want a specific answer to a specific, anxious question.
The graph was worth keeping. The objective on top of it was not. engine.py and app.py retain the older direction and still run.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file epicenter_mcp-0.1.0.tar.gz.
File metadata
- Download URL: epicenter_mcp-0.1.0.tar.gz
- Upload date:
- Size: 57.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51760d829132c4eb6c1bd23eb0fd26191100a989d1075ae1210eb04225524a87
|
|
| MD5 |
e70f8056e78a6821cc59862191e10f56
|
|
| BLAKE2b-256 |
36d49c9aa29b204898701532ebf6a539736b9a6656e6106c598ba986626d9c62
|
File details
Details for the file epicenter_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: epicenter_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5c81fd2db6b31c3ba958d03c005500adc47c8849f685dfb3018731e60de2254c
|
|
| MD5 |
175363e3bf078ec295f0a30ead0af11d
|
|
| BLAKE2b-256 |
08f7ed230e189d85cfd8ba27bc64152c4fd0a4cbafe36209dfbd905d24511b5d
|