Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 index writes. gonzalo-mcp only 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:

languageextensions
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, or in-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 that abort()s will skip one file or kill the run. In-process also prints a warning naming every location searched — the GONZALO_PARSE_WORKER override, beside the executable, then each PATH entry. Pass --require-parse-worker to fail instead.
  • driver — full walk the 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.
  • driver also goes back to full walk on 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 one gonzalo was 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 matching gonzalo-parse-worker to clear it.
  • ignored — files and directories deliberately left out of the view: dependency and build-output directories, generated bundles (*.min.js and friends), and anything .gitignored. Use --include <path> to re-admit a vendored path you do want indexed. --include cannot 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 ~ in GONZALO_ROOT (and in --root) is expanded to $HOME, so GONZALO_ROOT=~/.gonzalo works even though no shell is involved — neither bash nor zsh expands a tilde in --env KEY=~/path argument 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). status reports 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

toolanswers
viewswhich (repo, view_id) pairs exist, with file counts and indexed commit
statusis the server up, which root, how many views

Whole-view — start here in an unfamiliar repo; none need a symbol name.

toolanswers
overviewcounts, breakdown by kind and language, largest files
topmost referenced (fan_in), most calls out (fan_out), most ambiguous (definitions)
listenumerate symbols filtered by path prefix, kind, name substring
unreferenceddead-code candidates — heuristic, see below

Per-symbol — for a name you already have.

toolanswers
searchwhere is this defined
nodedefinitions + callers + callees in one call
callers / calleeswho calls this / what does this call
exploreevery reference, with paths
impacttransitive caller closure, resolution-gated; takes max_depth

Across views

toolanswers
diffsymbols 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.

toolanswers
record_getread one record: body, kind, revision, provenance
record_listthe keys in a namespace, or in one collection of it
record_putwrite a record — only when the server allows writes
record_deletedelete 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.

toolother parameters
search, node, callers, callees, explorename (required)
impactname (required); max_depth, where 0 or omitted means unbounded
diffview_a, view_b (both required)
overviewlargest: how many of the largest files to list (default 20)
topby (required): fan_in, fan_out or definitions; limit (default 20)
listpath_prefix, kind, name_contains, limit (default 100)
unreferencedpath_prefix, kind, name_contains, exclude_tests (default true), limit (default 100)

The record tools take no view selector:

toolparameters
record_get, record_deletenamespace, collection, id (all required)
record_listnamespace (required); collection to narrow it
record_putnamespace, 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

symptomcause
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":0nothing indexed yet, or GONZALO_ROOT points somewhere unexpected — check the root it reports
a directory named ~ appearedGONZALO_ROOT=~/… was not expanded; use an absolute path
"no indexed view" errorthe selector does not match — call views
results describe code that changedstale view — compare base_commit to HEAD and re-index
new tools missing after an upgradethe client is still running the old binary — reconnect the MCP server