A Go-only code indexer that builds a queryable graph of packages, files,
functions, types, and the call edges between them — with semantic search
over doc comments and an agent-authoring layer for higher-level notes
(Learning) and architectural concepts (Concept).
Built on modusgraph, so the same code targets an embedded Badger directory or a Dgraph cluster without changing.
Two audiences:
- Humans running ad-hoc CLI queries to navigate a codebase.
- Coding agents — either Claude Code via the MCP server, or other Go programs that import the library.
Phase 1. Go-only indexing (golang.org/x/tools/go/packages + go/types).
Intra-module call edges resolve; cross-module CALLS are deferred. See
pkg/store/types.go for the full schema.
go build -o codebase-graph ./cmd/codebase-graph
# Index a Go module into an embedded Badger store (default).
./codebase-graph index /path/to/some-go-module
# Query.
./codebase-graph callers github.com/foo/bar.SomeFunction
./codebase-graph callees github.com/foo/bar.SomeFunction
./codebase-graph list-functions github.com/foo/bar
# Semantic search (requires an embedding provider — see below).
./codebase-graph search github.com/foo/bar "natural-language description"Default store URI is file://$XDG_CACHE_HOME/codebase-graph. Override with
--store or CBG_STORE.
| subcommand | what it does |
|---|---|
index <path> |
Walk the go.mod at <path> and populate the store. Re-runs skip unchanged files via sha256 file-hash. |
search <module> <query> |
Exact name match + similarity search over function doc comments. |
callers <qn> |
Functions that call the given qualified name. |
callees <qn> |
Functions called by the given qualified name. |
list-files <module> |
All indexed files in a module. |
list-functions <module> |
All indexed functions, paginated. |
mcp |
Run the MCP server on stdio or Streamable HTTP. |
Run ./codebase-graph with no args for the full flag listing.
Two transports:
- stdio — default. One process per client. Wire into Claude Code via the
command:form below. - Streamable HTTP — multi-tenant network server with per-user bearer-token auth. Deploy this when teammates should contribute to a shared graph from their own Claude Code instances.
{
"mcpServers": {
"codebase-graph": {
"command": "/absolute/path/to/codebase-graph",
"args": [
"--store", "file:///absolute/path/to/store",
"--embedding-url", "http://localhost:11434",
"--embedding-model", "bge-m3:latest",
"--embedding-dims", "1024",
"mcp"
]
}
}
}The store must be indexed first (codebase-graph index ...) — the MCP
server doesn't re-index on demand. Index whenever the code changes.
Run codebase-graph mcp --addr :8080 --tokens-file tokens.json on a host
that can reach your Dgraph cluster. Each contributor gets their own bearer
token; writes are auto-stamped with the token's user identity so you can
attribute every Learning to the person who created it.
Tokens file (tokens.json) — JSON array of {token, user} pairs:
[
{"token": "sk-cbg-alice-...", "user": "alice@team.com"},
{"token": "sk-cbg-bob-...", "user": "bob@team.com"}
]Pick high-entropy tokens (32+ random bytes hex/base64); they're matched
literally. The user field is what gets persisted on Learning.agent —
use something stable and human-readable (email, github handle).
Server start:
./codebase-graph \
--store dgraph://cluster.internal:9999 \
mcp --addr :8080 --tokens-file /etc/codebase-graph/tokens.jsonRefuses to start if the tokens file is empty (no implicit unauthenticated mode for HTTP — that's a footgun).
TLS: terminate at a reverse proxy (nginx, caddy, k8s ingress). The server speaks plain HTTP — cert lifecycle is the proxy's problem.
Client config (Claude Code). Two homes for this config, depending on whether the deployment is personal or shared via the repo:
~/.claude.json(per user, all repos). Find or create the top-levelmcpServerskey and paste the"codebase-graph": { ... }block inside it. Best when the token is yours and you want the MCP server available in every Claude Code session..mcp.jsonat the repo root (committed, shared with the team). Claude Code picks this up automatically when launched inside the repo. Don't commit a literal token — use${VAR}expansion and have each contributor export the variable in their shell rc.
{
"mcpServers": {
"codebase-graph": {
"type": "http",
"url": "https://codebase-graph.team.internal/mcp",
"headers": {
"Authorization": "Bearer ${CBG_TOKEN}"
}
}
}
}For a personal ~/.claude.json entry, swap ${CBG_TOKEN} for your
literal token. For an open-source project served with
--allow-anonymous-reads, omit the headers block entirely — visitors
who clone the repo will connect read-only with no setup.
Endpoints:
POST /mcp— JSON-RPC requests. RequiresAuthorization: Bearer <token>.GET /healthz— unauthenticated liveness probe for load balancers.
Identity rules:
add_learningignores any client-suppliedagentargument when running under HTTP — the auth-derived user always wins. (Spoofing-resistant.)- Under stdio (no auth),
agentis still honored as before.
Rotating a token: edit tokens.json and restart the server. SIGHUP-reload
isn't supported yet.
For projects that want their graph publicly browsable while keeping
contributions gated, add --allow-anonymous-reads:
./codebase-graph \
--store dgraph://cluster.internal:9999 \
mcp --addr :8080 \
--tokens-file /etc/codebase-graph/tokens.json \
--allow-anonymous-readsWhat changes:
initialize,tools/list, and every read-only tool (list_projects,search_functions,find_callers,find_callees,get_function,list_concepts/get_concept/list_concept_members/search_concepts,list_learnings_for,search_learnings,export_project_graph) are reachable with noAuthorizationheader.- Write tools (
add_learning,add_concept,tag_with_concept) still require a valid bearer; anonymous attempts get 401. - A bearer token is still validated when present — sending a bad token is 401 even on read endpoints (silently ignoring it would mask programming errors and weaken provenance).
You can also run with no tokens file at all for a fully public, read-only deployment:
./codebase-graph mcp --addr :8080 --allow-anonymous-readsThe server refuses to start with neither --tokens-file nor
--allow-anonymous-reads — that combination would accept zero requests.
The graph is a bidirectional context cache for coding agents. The win is two-sided:
- Read side: fewer round trips. Instead of grepping a repo, opening files, and scrolling through hundreds of functions to find the right one, Claude calls one MCP tool and gets a typed answer.
- Write side: durable learning. When Claude figures out something
non-obvious during a task — a hidden constraint, a confusing convention,
the reason a function exists — it calls
add_learningand that note sticks to the function. The next agent (you, someone else, a future you) hits it on the next query.
To get Claude to actually use the graph, add a short section to your
project's CLAUDE.md so it knows when to reach for which tool. Drop this
in (or adapt it):
## Codebase navigation via codebase-graph
This project is indexed in the codebase-graph MCP server. **Prefer graph
queries over filesystem traversal** when navigating Go code — they're
faster, more reliable, and surface accumulated context from prior agents.
**Before grepping or opening files at random:**
- Looking for a function by name or behavior →
`search_functions(module, query, limit?)`
- Tracing call relationships → `find_callers(qn)` / `find_callees(qn)`
- "How does this codebase handle X?" → `search_concepts(query)` /
`search_learnings(query)`
- About to dive into a function → `list_learnings_for(target_kind, target)`
first. Prior agents may have already noted the gotcha you're about to
rediscover.
**After learning something non-obvious about the code, write it back.** If
you find a hidden invariant, a convention that isn't visible in the
source, a "this looks like a bug but isn't", a function that has no
documentation and you've taken the time (spent tokens) to understand it,
— call `add_learning` with `target_kind`, `target` (qualified name),
`body` (state the rule, then a one-line "Why:"), and optionally
`kind` (`gotcha` / `convention` / `note`). If the insight is architectural
and spans many files, use `add_concept` instead and tag the relevant code
nodes with `tag_with_concept`.
Keep learning bodies tight — written for a future agent reading the
codebase cold, not for yourself today. Avoid restating what's obvious
from the source.
**Only write from `main` (or the repo's default branch).** Feature
branches get abandoned, force-pushed, or significantly reworked before
merging — learnings written from them often describe code that never
lands, leaving the graph full of claims about functions that no longer
exist. Before calling `add_learning` / `add_concept` /
`tag_with_concept`, run `git rev-parse --abbrev-ref HEAD` and verify
you're on the default branch. If you're on a feature branch and the
insight is about code *you're introducing in this branch*, hold the
note in conversation and write it after the PR merges. Insights about
*pre-existing* code you happened to read or review while on a branch
are fine to write from anywhere — the claim is about behavior that's
already on main.A few notes:
- Reads cost ~nothing. Tool calls are tens-of-milliseconds against the local cluster; far cheaper than re-tokenizing source. Encourage Claude to query liberally.
- Writes are de-duped.
add_learningderives a stable id from<agent>:<sha256(target+body)>— re-writing the same insight is a no-op upsert, not a duplicate. - Provenance is automatic under HTTP transport. With per-user bearer tokens, every learning auto-stamps the contributing teammate's identity. No client-side configuration needed.
Code queries
search_functions(module, query, limit?)— exact-name + semantic.find_callers(qualified_name)/find_callees(qualified_name).get_function(qualified_name)— full detail.
Agent annotations — write durable context onto graph nodes.
add_learning(target_kind={function|type|package|file|concept}, target, agent, body, kind?, title?, learning_id?)list_learnings_for(target_kind, target)search_learnings(query, limit?)— semantic over Learning bodies.
Concepts — higher-level mechanisms that span code (e.g. "MVCC", "LSM").
add_concept(module, name, summary?, description?, related_to?)tag_with_concept(concept_id, target_kind, targets[])— attach code nodes.list_concepts(module)/get_concept(concept_id)/list_concept_members(concept_id, kind?).search_concepts(query, limit?)— semantic over Concept descriptions.
Visualization — bulk export for rendering.
-
export_project_graph(module, include_*: bool)— every node and edge for a module as D3-compatible JSON:{ "module": "github.com/dgraph-io/badger/v4", "nodes": [ {"id": "0x5", "label": "Open", "kind": "function", "qualified_name": "..."}, ... ], "links": [ {"source": "0x5", "target": "0x3", "kind": "in_file"}, ... ] }Node
idis the Dgraph uid;kindis one ofproject | package | file | function | type | concept | learning. Linkkindis the graph predicate (calls,in_file,in_pkg,in_file_t,in_package,imports,includes_*,about_*,related_to). Useinclude_*: falseflags to shrink the export —include_functions: falseis the biggest lever for large modules (functions are usually the bulk).A single-page D3 visualization example app is checked into this repo at deploy/d3-viewer/index.html.
Apps that already speak Go can skip the MCP transport and import directly:
// go.mod
require github.com/matthewmcneely/codebase-graph v0.0.0
replace github.com/matthewmcneely/codebase-graph => /path/to/codebase-graphimport (
"context"
"time"
"github.com/matthewmcneely/codebase-graph/pkg/embed"
"github.com/matthewmcneely/codebase-graph/pkg/index"
"github.com/matthewmcneely/codebase-graph/pkg/store"
)
ctx := context.Background()
// Optional: gate startup on a remote cluster being reachable.
if err := store.WaitReady(ctx, "dgraph://localhost:9999", 60*time.Second); err != nil {
log.Fatal(err)
}
prov, _ := embed.Build(embed.DefaultConfig())
st, err := store.Open(ctx, "dgraph://localhost:9999", store.WithEmbeddingProvider(prov))
if err != nil { log.Fatal(err) }
defer st.Close()
if _, err := index.Index(ctx, st, "/path/to/some/repo"); err != nil { log.Fatal(err) }
learnings, _ := st.LearningsForFunction(ctx, "github.com/foo/bar.SomeFunc")
similar, _ := st.SimilarFunctionsByDoc(ctx, "github.com/foo/bar", "rebalance shards", 10)The internal/mcp package is not importable on purpose — it's a
CLI-only JSON-RPC adapter, not part of the library contract.
Project ── owns ──> Package ── contains ──> File
│ │
│ ├── Function (with `calls` edges to other Functions)
│ └── Type
│
└── imports another Package
Concept ── includes_{packages,files,functions,types} ──> code nodes
── related_to ──> other Concepts
Learning ── about_{package,file,function,type,concept} ──> any of the above
Reverse-edge convention: forward edges live on the "annotation/owner"
side; code nodes find their annotations via ~about_* / ~includes_*
DQL traversals.
Identity scheme (durable across machines and storage backends):
| node | identity |
|---|---|
| Project | Go module path (from go.mod) |
| Package | full import path |
| File | <module_path>|<rel_path> |
| Function / Type | qualified name (pkg.Name or pkg.Receiver.Method) |
| Learning | agent-supplied learning_id (MCP auto-derives as <agent>:<sha256>) |
| Concept | <module_path>::<slug> |
--store accepts:
file:///abs/path— embedded Badger directory. Default. Fast, zero-config. Single-writer: only one process can hold the lock at a time. Use for CLI + single-agent setups.dgraph://host:port— external Dgraph cluster. Multi-process safe. Use when multiple apps (e.g. oss-daily plus an MCP agent) share the graph.
A reference local Dgraph cluster is in
deploy/local-cluster/docker-compose.yml.
Ports are shifted out of Dgraph's defaults so this cluster coexists with
others you may run for Dgraph core development:
docker compose -f deploy/local-cluster/docker-compose.yml up -d
./codebase-graph --store dgraph://localhost:9999 index /path/to/module| service | port | purpose |
|---|---|---|
| alpha gRPC | 9999 | client URI |
| alpha HTTP | 9998 | /health, admin, Ratel |
| zero gRPC | 5999 | coordination |
| zero HTTP | 6999 | zero admin |
The store uses an OpenAI-compatible embedding provider. Defaults to Ollama:
./codebase-graph \
--embedding-url http://localhost:11434 \
--embedding-model bge-m3:latest \
--embedding-dims 1024 \
index /path/to/moduleAlso tested with LMStudio (text-embedding-embeddinggemma-300m-qat at 768
dim) and any OpenAI-compatible endpoint. Override via flags or env
(CBG_EMBEDDING_URL, CBG_EMBEDDING_MODEL, CBG_EMBEDDING_DIMS,
CBG_EMBEDDING_API_KEY).
--no-embeddings skips wiring entirely — faster indexing, no semantic
search.
Note: embedding dims are baked into the Dgraph schema on first
Open(). Switching models with a different dim count requires a fresh
store (rm -rf the file:// dir or down -v the cluster).
cmd/codebase-graph CLI + MCP server entry point
pkg/store public: typed wrapper over modusgraph Client
pkg/index public: two-pass Go-module walker
pkg/harvest public: mine ~/.claude/projects/ sessions into Learnings/Concepts
pkg/embed public: embedding-provider config
internal/mcp private: stdio + HTTP JSON-RPC adapter (CLI-only)
deploy/local-cluster Dgraph cluster compose recipe
testdata/ fixtures used by tests
go test ./...Tests use file:// stores under t.TempDir() — no external services
required (Ollama / Dgraph cluster are not needed for the test suite).
Tracked items that didn't make v1 but are worth picking up:
MCP HTTP transport.
- SIGHUP-reload of
tokens.json. Rotating a token currently requires a server restart. Trivial to add — watch for SIGHUP and re-read the file into the registry atomically. - Identity-stamping on
add_concept/tag_with_concept. Today onlyadd_learninghonors the auth-derived user. Concepts and tags don't carry per-write attribution, so you can't tell who proposed a concept or who tagged what. Same plumbing pattern asadd_learning. - In-process TLS. Intentionally out of scope — reverse proxies handle it better. Revisit if there's a deployment context where adding a proxy is friction (e.g. small teams running the binary directly on a laptop).
- Per-token rate limits. Not needed for trusted-team deployments; worth doing if the server ever sits on a less-trusted network.
- Read-only token role. Currently every token has full read+write
access. A role flag on the token entry (
"role": "read") would let teams give broad query access without write privileges.
Harvester.
- Top-level const/var indexing. The Go indexer captures Functions and Types only. Behavior encoded in package-level constants (e.g. GraphQL SDL strings, regex tables, default-options structs) can't be a Learning target at function granularity — the harvester correctly falls back to file-level attachment, but a structural fix is to index those nodes too.
- Non-Go indexers. The Ratel session in the v1 harvest hit 0/6 admit
because Ratel is largely TypeScript/React, which the Go indexer doesn't
see. A TS/JS indexer (using
tsserverortree-sitter-typescript) would unlock JS-heavy repos. Function.Signature-aware source verification. The apply-stage source verifier reads the function's source from disk and checks for symbol mentions. It could also pull the indexedSignaturefield and do a tighter parameter-count / type-shape check against the body's claims. Catches more "describes an in-progress edit" failures without needing the repo checked out.- Provenance fields on
Learning. Todayagentis the only origin signal. Addingsource_session_id/source_chunk_index/evidence_quoteas typed predicates (not body suffix) would let reviewers query "show me everything from session X" or "amend this learning, here's the new evidence." - Semantic dedup at apply time. The pipeline currently dedups by
<agent>:<sha256(target+body)>— only catches identical bodies. WiringSearchLearnings(body)filtered to the same target with a cosine threshold would amend near-duplicates instead of inserting siblings.
Performance.
- Tight projection on
FindFunctionByQN. modusgraph's default Query expansion pulls the full Function struct including theCallspredicate. On hot functions with thousands of callers this generates very large responses (we already hit the 4 MB gRPC default once; bumped to 32 MB). A projected query that only fetches uid + line range would be more bandwidth-efficient.