Skip to main content
Glama
README.md
# temporal-graph-mcp (experimental)

A small, standalone MCP server that answers one question: **can the Baligács
(2026) "Temporal Cliques Admit Linear Spanners" algorithm compress a *code*
knowledge graph the way it compresses a linguistic co-occurrence graph?**

It is **not** a fork of [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp)
(a real `gh repo fork` of that MIT-licensed project was made separately, for
provenance/attribution only — no code from it is used here; it's a 38MB+ C
codebase with 158 tree-sitter grammars and a custom Cypher engine, well
outside the scope of this experiment). This project borrows only its **tool
naming convention** (`index_repository`, `search_graph`, `get_architecture`,
...) as a point of reference, and implements a small, honest subset of it.

## What's actually new here

[`corpus_engine`](../../corpus_engine) already applies the Baligács algorithm
to word co-occurrence cliques in a Turkish linguistic corpus. This project
mirrors that pipeline one-to-one, but the graph comes from **git commit
co-change history** instead of corpus co-occurrence:

| corpus_engine (words) | temporal-graph-mcp (code) |
|---|---|
| two words co-occur in a document/sentence | two files (or two functions) change together in a commit |
| edge timestamp = document date | edge timestamp = commit's unix time (earliest co-change kept) |
| PMI/NPMI threshold gates noisy pairs | `min_cochanges=2` gates one-off co-changes; commits touching too many items (>20 files / >60 functions) are dropped (mass rename/format/reflow noise) |
| maximal clique = "semantic field" | maximal clique = "files/functions that are really one subsystem" |
| spanner = sparse edge set preserving all temporal reachability | same, applied per coupling-clique |

Two granularities are supported, both from the same git log, no AST parser needed:

- **`file`** (default): nodes are file paths, from `git log --name-only`.
- **`function`**: nodes are `"<file>::<function>"`, extracted from `git log -p
  --unified=0`'s hunk headers (`@@ ... @@ def foo(...):`). Git already ships
  per-language "funcname" heuristics (Python, JS, Java, C, C++, Go, ...) used
  to generate that trailing context on a diff hunk — this just reuses it
  instead of vendoring a per-language AST parser. Whole-file adds have no
  funcname context (nothing to diff against) and are skipped at this
  granularity; file-level coupling already covers "these were added
  together".

The pure-stdlib algorithm itself (`vendor/spanner/`, `vendor/backend/algorithm/`)
is copied **verbatim** from
[`temporal-spanner-analyzer`](../../../Belgeler/GitHub/temporal-spanner-analyzer)
— no changes, so its existing test suite / correctness proof still applies.
The one addition is `graph_utils_lite.py`, a copy of that repo's
`maximal_cliques` (Bron-Kerbosch, Tomita pivot) with the pydantic
`GraphSchema` dependency stripped out (plain `dict[str, set[str]]` adjacency
instead), so this package needs nothing beyond the `mcp` SDK.

## Verified result (see `demo.py`)

Run against `temporal-spanner-analyzer`'s own git history, both granularities:

```
$ python demo.py <repo> file
30/46 commits used (rest touched < 2 or > 20 files)
47 files, 279 coupling edges (co-changed >= 2 times)
26 cliques, 43/47 files covered
total: 716 original edges -> 326 spanner edges (54.5% fewer edges)
tokens (tiktoken/cl100k_base): 10012 -> 4473 (55.3% fewer tokens)

$ python demo.py <repo> function
27/46 commits used (rest touched < 2 or > 60 functions)
97 functions, 798 coupling edges (co-changed >= 2 times)
47 cliques, 90/97 functions covered
total: 1799 original edges -> 619 spanner edges (65.6% fewer edges)
tokens (tiktoken/cl100k_base): 60921 -> 20897 (65.7% fewer tokens)

All cliques verified (both runs): reachability preserved, 7n bound honored.
```

The token counts are **measured, not inferred**: `tokens.py` tokenizes the
actual JSON edge-list payload (original vs spanner) an MCP tool would put on
the wire, with `tiktoken`'s `cl100k_base` encoding -- a widely used proxy
(Anthropic has no public offline tokenizer; `count_tokens` is an API call
now). Token savings track edge savings closely (55.3% vs 54.5%, 65.7% vs
65.6%), which is expected: both are counting the same JSON-shaped list, just
in different units.

Function-level compresses *better* here (67% vs 54%): finer-grained nodes
mean more, smaller, tighter cliques (some genuinely large — a 25-function
clique with 300 original edges compressed to 41), and the 7n bound is
relatively more generous per node than at file granularity. This isn't
guaranteed to hold on every repo, but it's consistent with the intuition
that coupling is a stronger, less noisy signal at function granularity
than at file granularity (a file with 10 unrelated functions dilutes the
file-level signal; the function-level graph doesn't have that problem).

(Exact numbers wobble slightly run-to-run — e.g. 53.5% vs 54.1% observed
across runs here — because Python's per-process hash randomization changes
`set` iteration order, which changes Bron-Kerbosch's pivot tie-breaking and
therefore which maximal cliques get found first. The savings stay in the
same ballpark every time; only the exact clique decomposition varies.)

Every clique is checked two ways before being reported: `verify_spanner`
confirms every pair of items still has a temporal path in the compressed
edge set (a silently-broken spanner is worse than no spanner), and the
result is asserted to be `<= 7n` per the paper's bound. `demo.py` hard-fails
(assert, not a warning) if either check doesn't hold.

**Why this matters for an LLM agent querying a code graph**: a real tool
like `get_architecture`'s clusters, or a coupling query, would otherwise
have to return all O(n²) edges of a tightly-coupled clique to answer "what's
coupled to X". Returning only the O(n) spanner edges answers the same
reachability question (which files/functions could a change to X eventually
reach, in commit-causal order) with far fewer edges — and, as measured
above, far fewer actual tokens on the wire. Same order of magnitude as
codebase-memory-mcp's "99% fewer tokens" claim, on a much smaller scale and
a different signal (coupling, not call-graph) -- and here it's a number
`temporal_compress` computes and reports itself, not a claim made about it.

## Token benchmark vs. the naive path

[`docs/TOKEN_BENCHMARK.md`](docs/TOKEN_BENCHMARK.md) — a small, right-sized
version of codebase-memory-mcp's own Graph-vs-Explorer token methodology:
real MCP tool calls vs. real `git`/`grep` commands for the same questions,
real `tiktoken` counts on both sides, one repo, four questions, including a
deliberate losing case (grep-only queries get no advantage — MCP overhead
can even cost slightly more). Run it yourself: `python token_benchmark.py
<repo_path> [name]`. **4.5x fewer tokens** on this run — an order of
magnitude below their 10-120x claims, which is expected given the much
narrower tool surface; not a claim that this project matches their result.

## Tool surface

Implemented (real, working):

- `index_repository(repo_path, name?, granularity="file"|"function")` — builds the coupling graph + runs compression, persists to `~/.temporal-graph-mcp/<project>.json`
- `list_projects()` / `index_status(project)` / `delete_project(project)`
- `get_architecture(project)` — coupling cliques reported as `clusters` (files-that-move-together, the coupling-graph analog of a real call-graph's community detection)
- `search_code(project, pattern, file_pattern?)` — thin `grep -rn` wrapper, **not** graph-augmented (no AST index to rank/dedupe by)
- `get_code_snippet(project, file_path, start?, end?)` — plain file read
- `temporal_compress(project)` — **the actual point of this project**: per-clique before/after edge counts AND actual token counts (real `tiktoken` tokenization of the edge-list payload, not a proxy) + repo-wide savings %
- `trace_path(function_name, project, mode="calls")` — Python-only, `ast`-based call graph built at index time (`call_graph.py`): BFS up to depth 3 for what a function calls and what calls it. Name-only resolution (no import/type resolution) — a call to `run()` links to *every* function named `run` in the repo, so common names fan out. Good enough for "what does X touch" on a small/medium repo; not a real symbol resolver.

Explicitly **not** implemented — each returns a clear "not supported in this
prototype" error rather than fabricating an answer: `trace_path` for
`mode != "calls"`, `query_graph`, `manage_adr`, `ingest_traces`,
`detect_changes`. These need a real import-resolved call graph or a Cypher
engine, which is exactly the part that makes the real codebase-memory-mcp a
38MB C project — out of scope for what this experiment is testing.

## Known limitations (intentional, for a v1 prototype)

- **Function identity is a diff hunk's funcname string, not a real symbol.**
  Two functions with the same name in two different files get distinct node
  IDs (`file::name`), but a *renamed* function loses continuity with its
  pre-rename history (git's funcname heuristic re-derives the context from
  the current hunk each time, it doesn't track renames) — a real AST/call
  graph wouldn't have this gap. Good enough to test the spanner idea; not a
  substitute for a real per-language resolver.
- **Coupling ≠ semantics.** Two files/functions that change together aren't
  necessarily *conceptually* related (could be pure incidental churn) the
  way two co-occurring words are more reliably semantically related. The
  `min_cochanges=2` / `max_items_per_commit` gates are a blunt filter for
  this, not a real signal-quality measure.
- **JSON-file storage**, not a real graph database — fine for a prototype
  index of a few dozen/hundred nodes, would not scale to a large monorepo as-is.
- **`trace_path`'s call graph is name-only, Python-only.** No import
  resolution, so two unrelated functions sharing a name (e.g. two `run()`
  methods on different classes) are both linked from any call to `run()`.
  Non-Python repos get an empty call graph and a clear error, not a
  fabricated one.

## Running it

```
pip install mcp                       # already present on this machine
python demo.py <repo_path> [file|function]   # standalone self-check, no MCP transport
python server.py                             # runs the MCP server on stdio
```

To register with Claude Code, add to `mcpServers` in `~/.claude.json`:

```json
"temporal-graph-mcp": {
  "command": "python",
  "args": ["C:/Users/user/OneDrive/Masaüstü/idk/temporal-graph-mcp/server.py"]
}
```

(Requires restarting Claude Code to pick up a new MCP server.)

## Attribution

- Algorithm: Baligács (2026), *"Temporal Cliques Admit Linear Spanners"* —
  implementation vendored from `temporal-spanner-analyzer` (this user's own
  project).
- Tool-naming inspiration: [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp)
  (MIT license) — no code reused, see note above.