DEV Community

hugolesta
hugolesta

Posted on

Give Your Agent a Map of Your Codebase, Not a Grep

Ask an agent "where is authentication handled in this repo?" and watch what it does. It greps. It reads twenty files hoping one of them is the right one. It burns forty thousand tokens rebuilding, badly, a mental model that a parser could have produced in milliseconds.

The fix is to hand the agent a pre-built index instead of a filesystem. codebase-memory-mcp parses a repository into a persistent knowledge graph — functions, call paths, type relationships — and exposes it over MCP. Queries are sub-millisecond and return structure instead of file dumps.

Getting it onto a cluster took eleven pull requests, and six of them were fixing things that only appear when you actually run it. That second part is the useful part.


Why bother

File-by-file exploration is not just slow, it is quadratic in the wrong dimension. Every question re-reads the same files, because the agent's understanding dies with the context window. Nothing accumulates.

Here is what the agent gets instead — one repository of dotfiles, 486 nodes and 643 edges:

The graph UI showing one indexed repository — 486 nodes and 643 edges, filtered by node type, with a module node selected

The clusters are real structure. The dense knot on the right is a Neovim plugin directory where every module cross-references its siblings; the long green edges crossing the middle are the handful of files that reach across folder boundaries. Those edges are the whole argument — grep finds the string, but it cannot tell you that this file is load-bearing for something three folders away. Section, Module, File, Variable, Function, Folder, Branch are separate node types, so "show me only functions" is a filter rather than another search.

Upstream benchmarks five structural queries at roughly 3,400 tokens against a graph versus ~412,000 tokens doing the same work by grep and read — a 99% reduction. The accompanying preprint evaluates 31 real repositories and reports 83% answer quality with 10× fewer tokens and 2.1× fewer tool calls than file-by-file exploration.

Treat those as vendor numbers, because they are. The direction is what matters, and the direction is not subtle. Three things change in practice:

Questions about structure become answerable. "What calls this function, transitively?" is a graph traversal, not a search. Grep finds the string; it cannot tell you the call path, and an agent reconstructing that from twenty file reads will get it subtly wrong.

Cost stops scaling with repo size. A grep-based agent on a 500-file repository burns roughly ten times what it burns on a 50-file one, for the same question. A graph query costs the same either way — you paid the parsing cost once, at index time, in a sidecar nobody is waiting on.

Context survives the session. The index is on a volume, not in a context window. Restart the agent, ask again, and the map is still there.

The honest limit: this indexes structure, not intent. It will tell you every caller of reconcile() and nothing about whether the retry logic is correct. It replaces the expensive mechanical part of code exploration, not the part where you think.

Which is also why running it as shared infrastructure is the point. One index, kept current by a sidecar, queried by every agent — rather than each agent re-deriving the same map on every conversation and throwing it away.


The architecture

flowchart TD
    A["MCPServer CR — kmcp-system"] -->|"controller reconciles"| B["Pod — four containers"]
    B --> C["fetch-binary — downloads release"]
    B --> D["git-sync — clones and indexes"]
    B --> E["mcp-server — stdio via agentgateway"]
    B --> F["graph-ui — nginx to daemon"]
    C -->|"verified tarball"| G[("PVC — binary, cache, repos")]
    D -->|"shallow clone per repo"| G
    E -->|"reads graph"| G
    E -->|"streamable HTTP — /mcp"| H["kagent agent"]
    F -->|"Tailscale Ingress — HTTPS"| I["Browser on the tailnet"]
Enter fullscreen mode Exit fullscreen mode

Four containers on one volume. The split matters: fetching, syncing, serving, and visualising all fail in different ways, and keeping them separate means a failure in one doesn't take out the others.


Skip the container image

Upstream publishes release binaries for six platforms and no container image. The obvious move is to build one — a Dockerfile, a GitHub Actions workflow, a GHCR package, a pull secret if the package is private.

I built exactly that, then deleted it two hours later. The chart now fetches the official binary onto the volume instead:

archive="codebase-memory-mcp-linux-${ARCH}-portable.tar.gz"
base="https://github.com/DeusData/codebase-memory-mcp/releases/download/${VERSION}"

wget -q "${base}/${archive}"
wget -q "${base}/checksums.txt"
grep " ${archive}\$" checksums.txt | sha256sum -c -
tar xzf "${archive}"
Enter fullscreen mode Exit fullscreen mode

The -portable archives are fully static builds, so the binary runs on any base image regardless of libc. Checksum verification is not optional here — you are pulling 37 MB of executable over the network into a cluster.

This removed a Dockerfile, a build workflow, a registry package, and its visibility settings. Net −44 lines and one fewer thing to keep current when upstream tags a release. The precedent was already in the repo: a sibling chart runs its server from a ConfigMap for the same reason — nothing to build, publish, or keep updated for arm64.

The tradeoff is honest: the artifact is fetched at pod start rather than baked into an immutable image. For a homelab pulling an open-source binary, that's the right trade. For something on a critical path, build the image.


Index only when the commit moves

The sidecar clones each repo and re-indexes it. The naive version re-indexes every tick:

head="$(git -C "$dst" rev-parse HEAD 2>/dev/null || true)"
[ -n "$head" ] || return 0
[ "$head" = "$(cat "$stamp" 2>/dev/null || true)" ] && return 0

if "$bin" cli index_repository --repo-path "$dst" >/dev/null 2>&1; then
  printf '%s' "$head" > "$stamp"
fi
Enter fullscreen mode Exit fullscreen mode

A full re-index costs about 30 seconds of CPU on a Raspberry Pi 4. On a 15-minute tick across four repositories that's most of a core burned on repos where nothing changed. The stamp file makes an idle repo free.

Note the stamp is written only on success. A failed index leaves the old value in place, so the next cycle retries instead of marking a broken index as current.

Each repository gets its own stamp (.indexed-<name>). A single shared stamp means every repo invalidates every other one, and all four re-index forever.


Field notes

The chart is 300 lines. Six of the eleven PRs were failures that no amount of reading documentation would have surfaced.

A "hang" is often a refusal. The MCP endpoint accepted TCP connections and then never answered. kagent reported context deadline exceeded; the network looked fine. The server was logging the real reason on every request:

exact executable identity could not be verified (cache-private)
  ancestor 'cache' is not a usable private-directory parent
  (must be owned by you, not world-writable...)
Enter fullscreen mode Exit fullscreen mode

The storage provisioner creates the PVC root 0777. The binary walks the ancestors of its cache directory and declines to run if any is world-writable — but it still accepts connections in that state. chmod 755 on the volume root fixed it. Compare against a known-good sibling service when something hangs: a working MCP server answering in 50 ms next to yours answering never is a much stronger signal than a timeout alone.

Transport adapters expand your shell variables. The container crashed with error looking key 'bin' up: environment variable not found. The launcher script used a bin shell variable; the stdio transport adapter expands $... against its own environment before handing the string to the shell. Write literal paths in anything a transport adapter execs.

Sidecar limits are sized for the wrong job. The fetch container inherited the git sidecar's 128 MiB limit and was OOMKilled in a loop. A 37 MB archive unpacks to a 280 MB binary — tar xz was never going to fit. Measure before you guess: the actual index run peaks at 22 MiB across the whole process tree, because the work happens in an already-running daemon.

Loopback-only services check more than the bind address. The graph UI binds 127.0.0.1 with no flag to change it. Putting a proxy in front got a 403 — it validates the Host header. Rewriting Host got the HTML through and 403'd every asset, because it also validates Origin, and browsers send Origin on subresource requests but not on the initial navigation. That asymmetry is why the page loaded and looked broken:

proxy_set_header Host "127.0.0.1:9749";
proxy_set_header Origin "http://127.0.0.1:9749";
Enter fullscreen mode Exit fullscreen mode

Worth saying plainly: that is a CSRF guard, and rewriting both headers defeats it deliberately. It is only defensible because the tailnet is the security boundary and the UI has no authentication of its own. If it were reachable from anywhere else, this would be the wrong answer.

BusyBox is not GNU. An earlier attempt used sed -u to rewrite the header in a socat pipe. BusyBox sed has no -u, so it died instantly; without it, the filter buffers and the request never completes. Use an HTTP-aware proxy to rewrite HTTP headers.

expose annotations forward the port verbatim. A Tailscale expose annotation on a Service publishes it on that Service's port. The UI was live at :8080 and a browser hitting the bare hostname got nothing on 443. A Tailscale Ingress terminates HTTPS on 443 with a tailnet certificate, which is what you actually want:

spec:
  ingressClassName: tailscale
  tls:
    - hosts: ["codebase-memory"]
Enter fullscreen mode Exit fullscreen mode

Exit codes lie. One helmfile apply failed with another operation is in progress and the wrapper still exited 0. I reported the fix as deployed; it was not. The previous apply was still holding the release lock, blocked on --wait for a pod that could never become ready. Check the resource, not the return code.


Closing

Four repositories, 4173 nodes, 4802 edges, re-indexed automatically when any of their HEADs move. The agent answers "where is X defined" from a graph instead of a grep, and the 3D view is one hostname away on the tailnet.

The binary worked on the first try. Everything between a working binary and a working service is where the eleven PRs went — a loopback-only bind, a world-writable volume root, a header check, an OOM limit sized for the wrong job. None of it is in anyone's documentation, because none of it is interesting until it is the only thing standing between you and a working service.

Top comments (0)