The MCP server
gonzalo-mcp exposes the code graph to an agent over MCP (stdio). An agent can ask
where a symbol is defined, who calls it, what a view contains, and what changed
structurally between two views.
The two-step model
The single most important thing to know:
gonzalo indexwrites.gonzalo-mcponly reads.
The server never indexes anything. Install and register it without indexing and you get a server that answers every query with "no indexed view" — correctly, but forever. Indexing is not optional and not discoverable from the server.
gonzalo index ──writes──▶ store root ◀──reads── gonzalo-mcp ◀── agent
(~/.gonzalo)
A view is one indexed snapshot of one repo, addressed by (repo, view_id).
repo is any identifier you choose (by convention owner/name); view_id is
typically a branch. Multiple repos and multiple views share one store root cleanly —
each gets its own SQLite graph under <root>/graphs/<encoded-repo>/<view>.db.
Install
Three binaries are involved and they must all be at the same version: gonzalo,
gonzalo-mcp, and gonzalo-parse-worker. The last one isolates tree-sitter parsing in
a subprocess so one bad file skips instead of aborting the whole index. Indexing works
without it, falling back to in-process parsing — so it is not optional in practice,
just easy to forget. gonzalo index now says which mode it is in, and
--require-parse-worker turns the fallback into a hard error for CI and container
builds that should not take isolation on trust.
Apple Silicon: the release archive
Releases carry one prebuilt archive holding all three, which is the shortest path to a
set that cannot be mismatched. Take the tag from the
latest release — or let gh
read it for you:
tag=$(gh release view --repo caliban-ai/gonzalo --json tagName -q .tagName)
base="https://github.com/caliban-ai/gonzalo/releases/download/$tag"
pkg="gonzalo-$tag-aarch64-apple-darwin"
curl -fsSLO "$base/$pkg.tar.gz" -O "$base/$pkg.tar.gz.sha256"
shasum -a 256 -c "$pkg.tar.gz.sha256"
tar xzf "$pkg.tar.gz"
install -m 755 "$pkg"/gonzalo "$pkg"/gonzalo-mcp "$pkg"/gonzalo-parse-worker ~/.cargo/bin/
Archives start with the first release after v0.5.0; earlier releases carry no assets,
so build from the registry below if you need one of those.
The binaries are ad-hoc signed, not notarized. A curl download runs as-is; a
browser download is quarantined and needs xattr -d com.apple.quarantine "$pkg"/*.
macOS arm64 is the only prebuilt target. Everywhere else, build from the registry.
Anywhere else: from crates.io
v=0.7.0 # the version you want; `cargo search gonzalo-cli` shows the newest
cargo install "gonzalo-cli@$v" "gonzalo-mcp@$v" "gonzalo-parse@$v"
Pin the version on all three. They are separate crates, so nothing stops them landing
at different versions — and a gonzalo newer than its worker will index a view with
the old parser and then mark that view current, which no later re-index corrects.
An unpinned install can also silently serve a stale version for up to an hour after a
release, while the crates.io sparse index catches up.
Cargo installs to ~/.cargo/bin. That directory is on PATH for interactive shells
via ~/.zshrc, but an MCP client typically spawns a non-interactive shell, which
reads ~/.zshenv instead. If the server fails to start, this is usually why: either
put the directory on PATH in ~/.zshenv, or register the server with an absolute
path to the binary.
Index
gonzalo index --root /Users/you/.gonzalo --repo acme/widgets --view main /path/to/checkout
Files are parsed by extension. Supported languages:
| language | extensions |
|---|---|
| Rust | .rs |
| Python | .py |
| JavaScript | .js .jsx .mjs .cjs |
| TypeScript | .ts .mts .cts .tsx |
| Go | .go |
| Java | .java |
| Kotlin | .kt .kts |
| C# | .cs |
| C | .c |
| C++ | .h .cpp .cc .cxx .hpp .hh |
| Ruby | .rb |
| PHP | .php |
| Bash | .sh .bash |
| Swift | .swift |
| Lua | .lua |
| Scala | .scala .sc |
| Elixir | .ex .exs |
How much each language records varies: imports, qualifiers and receiver types are recorded for some languages and not others. Capability boundaries says which.
Output tells you what happened:
parse: isolated worker /Users/you/.cargo/bin/gonzalo-parse-worker (beside the executable)
driver: full walk
files: 465
added: 465
modified: 0
deleted: 0
skipped: 0
ignored: 2 files, 9 dirs not descended
parse—isolated worker <path>when tree-sitter runs in a crash-isolated subprocess, orin-process (no worker found)when it does not. The two produce identical graphs, so this line is the only way to tell whether a grammar thatabort()s will skip one file or kill the run. In-process also prints a warning naming every location searched — theGONZALO_PARSE_WORKERoverride, beside the executable, then eachPATHentry. Pass--require-parse-workerto fail instead.driver—full walkthe first time; afterwards a git-diff-driven incremental pass that re-parses only what changed. A no-op re-index is well under a second.skipped— files a parse worker crashed or hung on.driveralso goes back tofull walkon its own when gonzalo's extraction format changes, so a parser improvement reaches files that did not themselves change. The format recorded against a view is the one the parse worker reported, not the onegonzalowas built with — so a worker left behind at an older version rebuilds the view and credits it honestly, and upgrading the worker later rebuilds it again. A mismatch prints a warning naming both versions; install a matchinggonzalo-parse-workerto clear it.ignored— files and directories deliberately left out of the view: dependency and build-output directories, generated bundles (*.min.jsand friends), and anything.gitignored. Use--include <path>to re-admit a vendored path you do want indexed.--includecannot override.gitignore, because a view must stay reproducible from the commit alone.
Keeping a view fresh
Views are snapshots. Re-index after checking out or pulling — it is incremental:
gonzalo index --root /Users/you/.gonzalo --repo acme/widgets --view main /path/to/checkout
gonzalo index --watch keeps a view current continuously, with a debounce and a
periodic full reconcile.
Because views are per-(repo, view_id), git worktrees pair naturally with them: index
each worktree under its own view_id and diff them.
Register
claude mcp add gonzalo --env GONZALO_ROOT=/Users/you/.gonzalo -- gonzalo-mcp
A leading
~inGONZALO_ROOT(and in--root) is expanded to$HOME, soGONZALO_ROOT=~/.gonzaloworks even though no shell is involved — neither bash nor zsh expands a tilde in--env KEY=~/pathargument position, and an MCP client hands the value to the process verbatim. Before this was fixed it created a directory literally named~in the current working directory and indexed nothing you could find (#211).statusreports the expanded path, so check it there if in doubt. Only a leading~is expanded, and~otheruser/...is left alone.
Restarting or reconnecting the MCP client respawns the server process, which is how a newly installed binary's tools become visible — a full client restart is not needed.
Writes are opt-in
record_get and record_list are always available. record_put and
record_delete are exposed only when the server is started with
--allow-writes (or GONZALO_MCP_ALLOW_WRITES=1), and when it is not, they are
absent from the advertised tool list — an agent never sees a tool it would not
be allowed to call. Mutating a store is the operator's decision, not the agent's:
claude mcp add gonzalo --env GONZALO_ROOT=~/.gonzalo -- gonzalo-mcp --allow-writes
Against a running daemon
Point the server at a gonzalod instead of a local directory, and records come
from that shared store (ADR 0007):
claude mcp add gonzalo \
--env GONZALO_DAEMON=http://gonzalo.internal:8080 \
--env GONZALO_TOKEN=… \
-- gonzalo-mcp
--daemon <url> does the same for a client that passes arguments rather than
environment. GONZALO_TOKEN is sent as a bearer token when the daemon requires
one; what the token is scoped to decides what the tools can reach, so a
read-only token and a read-only server are two independent locks.
In daemon mode there is no local graph root: views are assembled from
content-addressed slices fetched over the daemon, so the code-graph tools work
against whatever that store holds. status reports the daemon URL as its
root.
Verify
status → {"status":"ok","root":"/Users/you/.gonzalo","views":2}
views → [{"repo":"acme/widgets","view_id":"main","files":465,"base_commit":"9ea0860…"}]
status reports the number of indexed views, so a server pointed at an empty or wrong
store is visibly wrong rather than merely "ok". views lists the valid (repo, view_id) pairs — call it first rather than guessing a selector.
base_commit is the commit the view was last indexed at. Compare it against
git rev-parse HEAD to detect a stale view, which is the quiet failure mode: results
that are plausible but describe code that has moved on.
A selector error is an error
A query naming a view that does not exist returns a tool error that names the selector and lists the views that do exist:
no indexed view 'acme/widgets/mian'. This is a selector error, not an empty result.
Indexed views: acme/widgets/main. Call `views` to list them.
An empty result therefore means what it says: the symbol is not in that view. This was
not always true — see #210 — and it
matters because an agent reads [] as "nothing calls this" and reports it as fact.
The tools
Discovery
| tool | answers |
|---|---|
views | which (repo, view_id) pairs exist, with file counts and indexed commit |
status | is the server up, which root, how many views |
Whole-view — start here in an unfamiliar repo; none need a symbol name.
| tool | answers |
|---|---|
overview | counts, breakdown by kind and language, largest files |
top | most referenced (fan_in), most calls out (fan_out), most ambiguous (definitions) |
list | enumerate symbols filtered by path prefix, kind, name substring |
unreferenced | dead-code candidates — heuristic, see below |
Per-symbol — for a name you already have.
| tool | answers |
|---|---|
search | where is this defined |
node | definitions + callers + callees in one call |
callers / callees | who calls this / what does this call |
explore | every reference, with paths |
impact | transitive caller closure, resolution-gated; takes max_depth |
Across views
| tool | answers |
|---|---|
diff | symbols and references added/removed from view_a to view_b |
Records — the store itself, not the code graph. No view selector; these
address a record by namespace, collection and id.
| tool | answers |
|---|---|
record_get | read one record: body, kind, revision, provenance |
record_list | the keys in a namespace, or in one collection of it |
record_put | write a record — only when the server allows writes |
record_delete | delete a record — only when the server allows writes |
record_get on a key that holds nothing is an error naming the key, not an
empty result, for the same reason an unknown view is: an agent reads nothing as
"there is nothing there" rather than "you asked the wrong question". Deleted
records are hidden from both record_get and record_list. record_delete is
the exception — deleting an absent key succeeds and changes nothing, because to
a consumer that key is already gone.
A write that loses a race is not an error either: record_put answers
{"committed": false, …} carrying the record the store actually holds, and
record_delete answers {"deleted": false, …} the same way
(ADR 0005).
Re-read and retry; never retry blind.
diff is the most useful tool here for reviewing work: point it at a branch view and
a main view and it reports what changed structurally, which is a different and often
better question than what a textual diff shows.
top, list and unreferenced are bounded and report total and truncated
beside the items, so a capped list never masquerades as a complete one.
overview's counts are never truncated — only its largest_files is capped, by
largest. impact reports truncated when it stopped at max_depth with a
frontier still unexplored.
Parameters
Every tool except status and views selects a view with repo and view_id
(diff takes view_a and view_b instead), and all of those are required.
| tool | other parameters |
|---|---|
search, node, callers, callees, explore | name (required) |
impact | name (required); max_depth, where 0 or omitted means unbounded |
diff | view_a, view_b (both required) |
overview | largest: how many of the largest files to list (default 20) |
top | by (required): fan_in, fan_out or definitions; limit (default 20) |
list | path_prefix, kind, name_contains, limit (default 100) |
unreferenced | path_prefix, kind, name_contains, exclude_tests (default true), limit (default 100) |
The record tools take no view selector:
| tool | parameters |
|---|---|
record_get, record_delete | namespace, collection, id (all required) |
record_list | namespace (required); collection to narrow it |
record_put | namespace, collection, id, body (all required); kind (default Topic); expected_revision |
list and unreferenced take a symbol kind: one of function, struct,
enum, trait, impl, module, const, static, type_alias, class or
interface.
record_put's kind is a record kind instead — MemoryTier, Topic,
Session, Checkpoint, Ticket, TicketEvent, GraphManifest, or one of the
fleet kinds (Person, IdentityBinding, RoleGrant, ChannelConfig,
LinkToken, AuditEntry) — and defaults to Topic. An unknown kind is an
error rather than silently the default, and a tombstone cannot be written
directly: deleting is record_delete's job. Omitting expected_revision means
"this key must not exist yet", so a record_put cannot silently overwrite.
Capability boundaries
Read this before trusting a result.
The call graph is name-matched, not type-resolved. Two unrelated functions sharing
a name are one node. callees includes enum variants, constructors, and std methods
(Some, ok, from) alongside project functions. top by=definitions is the
ambiguity report: any name scoring above 1 is defined in several places, and every
traversal through it merges unrelated subgraphs.
callers and callees now say how ambiguous the name is. Each answers with a
definition_count beside the list, plus defined_in while there are few enough paths
to be useful (#249). A count above 1
means the list merges several same-named symbols and is not the question you asked; a
count of 0 means the name is defined nowhere in the view, which is what makes an empty
list readable. The list itself moved under a callers/callees key, so these two tools
return an object rather than a bare array.
impact follows only resolvable edges. It used to walk the name-matched graph and
return roughly half the repository for one seed; it now keys on (name, defining path)
and refuses to traverse an ambiguous reference, reporting the count in
ambiguous_edges instead (#207).
Read that count: non-zero means the true set may be larger than what you got. Method calls
(x.foo()) on a receiver whose type is unknown are not attributed at all and are
counted in receiver_unknown_edges, which stops a std method being credited to a
same-named project function
(#223). Treat the closure as a lead
list and confirm the load-bearing edges with callers.
Imports narrow an otherwise ambiguous name. When several files define a name and
the calling file has none of them, gonzalo consults that file's imports: use crate::model::make prefers the make under model.rs
(#252). This is lexical, not a
resolved module path — an import says which module a name came from, and that module
name almost always appears in its own file's path. If the import fits several candidates
the reference stays ambiguous, so this only ever narrows. Recorded for Rust, Python, Java,
Kotlin, JavaScript, TypeScript, and C/C++ (where a #include names a whole file
rather than one symbol, and is matched by path); a wildcard or glob import contributes nothing, since
it introduces names the file never spells out. An import naming something outside the
view — a JDK or npm type — matches nothing and the reference stays ambiguous, which is
the honest answer.
An import is read relative to the file that wrote it. A Python relative import
(from ..DataTypes import Action) anchors at the referencing file's own package, and an
absolute import matching several identical copies of a package tree resolves to the copy
nearest the referencing file
(#261). This is what makes a repo
holding a vendored reference tree beside your own work, or one package tree per
assignment, resolvable at all. A genuine tie is still left ambiguous.
A method call is attributed when the body says what the receiver is. b.get()
resolves to Beta's get when b came from Beta::new(), a Beta { .. } literal, or
a parameter written b: Beta / &Beta, and self.foo() takes the enclosing impl
(#251). This is syntax, not
inference: a receiver from a plain call (let b = make()), a generic, or a trait object
still declines and is still counted in receiver_unknown_edges. Rust only for now;
other languages record no receiver type. On gonzalo's own source impact on insert
went from 60 symbols reached to 219.
A call that names its type is now attributed to it. Beta::get() resolves to
Beta's get even when several types in the view define one, because the call site's
qualifier is recorded alongside each definition's owning type
(#248). Two limits are worth
knowing. The qualifier only ever narrows: when it names something the view does not
own — a file module, a dependency — it is ignored and the older rules apply, so this
never turns an answer into a decline. And it is recorded only where the grammar makes a
path unambiguously a path, currently Rust, C++, and PHP. Go's pkg.Func() and Java's
Foo.bar() are indistinguishable from a receiver call at this level, so they stay
unqualified rather than record a variable name as a type. A definition's owner shows
up in search and node results when it has one.
A call can be attributed to a module-level declaration, not just a function. A
great deal of modern TypeScript lives in module-level builder objects — a Zod schema, a
tRPC router — and calls inside them used to reach neither callers nor impact
(#268). They are now attributed to
the nearest named binding, and reported separately: callers still means functions
that call this, while module_callers carries the module-level ones. The nearest name
wins, so z.string() under a PORT: key reports PORT rather than the whole schema.
Deliberately over-inclusive in the same way value references are — a module-level
const rows = items.map(..) attributes its lambda's calls to rows — which is why the
two are separate fields rather than one list.
In JavaScript and TypeScript, an anonymous callback has no caller. A call inside
it('...', () => { ... }) records no enclosing function, because that arrow has no name.
Test files therefore contribute little to callers and impact, though they still count
for unreferenced. Functions bound to a name do resolve, including through a
higher-order wrapper such as React.memo
(#257).
callers on a type is always empty. Only call expressions are edges, so types,
traits, and structs have no inbound edges. Empty there means "not applicable", not
"unused".
unreferenced is a heuristic. A function used only as a value — and_then(f),
map_err(be) — is now recorded and no longer reported as dead
(#250). An unused name is still
hidden by any same-named symbol that is used. Confirm every hit against the source.
Passing a function is not calling it. A name handed to a call is recorded as a
value reference: it keeps unreferenced honest, and it is deliberately kept out of
callers, callees and top, so those stay call-graph answers. impact counts them
in value_edges rather than walking them, because extraction is per-file and cannot
tell an identifier naming a function from one naming a local — traversing them would
put false edges back into the walk. A non-zero value_edges means something depends on
the seed through a callback and is worth a look.
Calls inside Rust macro arguments are recorded, including in assert_eq! and
println!, but by a token-level rule: an identifier followed by a parenthesised token
tree. That is deliberately over- rather than under-inclusive — a tuple-struct pattern
like Some(_) reads as a call. In C and C++, calls inside a #define body are still
invisible, because the body is a single opaque preprocessor token.
Test functions are ordinary symbols and rank alongside production code. Check the path before concluding something is only used in tests.
Generated and vendored code is excluded by default, along with .gitignored build
output. If a query returns nothing you expected from a vendored path, that is why —
re-index with --include <path>.
Troubleshooting
| symptom | cause |
|---|---|
| server will not start | ~/.cargo/bin not on PATH for non-interactive shells — set it in ~/.zshenv or register an absolute path |
status shows "views":0 | nothing indexed yet, or GONZALO_ROOT points somewhere unexpected — check the root it reports |
a directory named ~ appeared | GONZALO_ROOT=~/… was not expanded; use an absolute path |
| "no indexed view" error | the selector does not match — call views |
| results describe code that changed | stale view — compare base_commit to HEAD and re-index |
| new tools missing after an upgrade | the client is still running the old binary — reconnect the MCP server |