Using the MCP server
kivgraph serve speaks MCP over stdio and, once a generation is published,
registers twelve tools; before that it registers index_project alone. This page is
about using them: which tool answers which question, how to name a symbol, and
how to read what comes back. Per-tool arguments live under
/docs/mcp-tools/ and on each tool’s own page.
What the server tells your agent
Section titled “What the server tells your agent”The initialize result carries an instructions string. This is it, literally:
Kivgraph answers "what breaks if I change this" from a published code graph over Go, TypeScript, Rust, Python and Dart. Before grepping or reading files to find callers, references or impact, call find_references or get_blast_radius; to read the code they name, call get_source. Check confidence and completeness: Python fallback facts can be CANDIDATE and external Dart packages can be UNRESOLVED.
Its edges are resolved by go/types, the TypeScript checker and rust-analyzer, not by matching names, so a reference list is complete for those languages and an empty one means nobody calls it. Grep cannot tell you that.
Rows are addressable: every one carries a repository, a repository-relative path, a qualified name and a line range, and every tool accepts that triple instead of a stable key.
Where it loses: a rare name in a single small repository is cheaper to grep, and one small file is cheaper to read than to outline. It wins on common names, on transitive impact, on cross-repository consumers and on proving an absence.A client keeps the tool names and their one-line descriptions resident in the
model’s context, and fetches the full input schemas only when a tool is about to
be called. That is why the routing advice lives in instructions and in the
descriptions rather than in the schemas: the schemas are not in front of the
model at the moment it decides whether to call anything.
Pick the tool by the question
Section titled “Pick the tool by the question”| The question | The tool |
|---|---|
| Who calls this, what references this | find_references |
| What breaks if I change this | get_blast_radius |
| What does this reach outward | trace_dependencies |
| Who uses this from another repository | find_cross_repo_consumers |
| Where is this declared | find_symbol |
| I do not know its name; which files do I open | find_by_intent, with keywords |
| What are this symbol’s package, signature, visibility and line range | get_symbol |
| What is declared under this path | get_file_outline |
| Give me the code of these symbols | get_source |
| Which repositories does the graph cover, at which commit | list_repositories |
| Is the published graph current | graph_status |
| Register projects and rebuild the graph | index_project |
index_project is the only tool that changes anything. Every other tool is
annotated readOnlyHint.
Address a symbol without a stable key
Section titled “Address a symbol without a stable key”Every tool that takes a symbol accepts one of two selectors:
stable_key, or- the triple
repositoryplus repository-relativepathplusqualified_name.
Exactly one of the two. Passing both is rejected with INVALID_ARGUMENT rather
than resolved quietly, because the two can disagree and answering one of them
answers a question nobody asked. repository and path narrow a
qualified_name; a stable_key already names one symbol, so combining them is
also rejected. path alone is rejected too: it is repository-relative, so it
requires repository.
A stable key is exact and durable, and it is also about thirty-five tokens of opaque base32 that nothing outside the server can read. You never need one, because every row a tool returns already carries the triple — which is why the default view of every query tool leaves the key out. Find the symbol, then feed the answer straight into the next call.
Step one, locate it:
{ "name": "MergeAll", "repo": "kivgraph", "limit": 3}find_symbol answers with the row you need. This is the default compact view:
the header states what the whole page shares, the row states where it is:
{ "name": "MergeAll", "kind": "func", "exported": true, "repository": "kivgraph", "symbols": [ { "at": "internal/facts/facts.go:516", "end": 542, "sig": "func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set" } ]}at is path:line under a header that names the repository, and the full
repository:path:line triple when the page’s rows come from more than one. Under
"view": "full" the same row is spelled field by field, key included:
{ "stable_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "name": "MergeAll", "qualified_name": "MergeAll", "kind": "func", "exported": true, "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 516, "end_line": 542}Step two, ask the real question. The header’s repository, the row’s path and
its qualified name become the call’s repository, path and qualified_name:
{ "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "incoming", "limit": 3}Note the one rename: a compact row reports the file inside at, a full row as
file_path, and arguments call it path. All of them are repository-relative.
For a reference question the two steps collapse into one:
find_references takes name on its own,
answers directly when a single declaration carries that name, and returns the
candidates as repository:path:line under AMBIGUOUS_SYMBOL when several do.
get_file_outline is the other way in
when you know the file or the package but not the name. It takes repository
and path and returns the declarations grouped by file, each as a
name@start-end entry with its kind, and those rows address the next call the
same way. The signature comes with response_format: "detailed".
If a qualified name matches more than one symbol, the call fails with
AMBIGUOUS_SYMBOL instead of a silent pick. What the error offers depends on
what is left to narrow. With no repository and no path it names the
candidates by where they are, repository, file and line range, because that is a
narrowing you can express in the next call. Once both were given and the name
still matches twice, only the key separates them, and then, and only then, the
error lists the stable keys.
A name that is absent says so distinctly. This is the captured answer to
get_symbol with repository kivgraph and qualified_name NoSuchThing:
SYMBOL_NOT_FOUND: qualified name "NoSuchThing" was not found under kivgraph; call it without repository and path to search the whole graphChoose a view
Section titled “Choose a view”Every query tool takes a view, and it changes the granularity of an answer,
never the answer. The same edges, with the same confidence and the same
provenance, spelled with or without the parts a row shares with every other row.
view | What you get | Where |
|---|---|---|
compact | The default. Whatever every row of the page shares is stated once in a header, and a row carries only what the header could not state for it. | Every query tool |
full | The field-per-row shape: every field on every row, including the stable keys and the language the compact view drops. Ask for it when a client was written against that shape. | Every query tool |
files | Only which files hold the facts, with a count each. The answer to “which files do I open”. | find_references and get_file_outline |
An unsupported value is INVALID_ARGUMENT, and so is files on a tool whose
answer is not a set of files — it fails rather than quietly returning something
else.
Pick compact unless you have a reason, which is why it is the default: over the
four reference questions of benchmarks/codebase-memory-comparison, measured on
the private benchmark corpus, they cost 13.594 tokens in the previous shape,
2.883 in the compact view and 963 in the files view, with precision and
recall unchanged.
view is never part of a cursor’s identity, so a page taken in one view can be
continued in another.
Four tools group their rows by file and spell each row as a label:
find_references,
trace_dependencies,
get_blast_radius and
get_file_outline. Three habits make their
answers readable:
- Read the header first. A field there applies to every row. A field missing from it means the rows disagreed and each carries its own.
- A row is a label.
qualified_name@start, orqualified_name@start-endwhen the declaration spans lines — the qualified name when the row has one and the bare name when it does not. It becomes an array when the row had to carry a column the header could not hoist, and the elements after the label are that tool’s columns in a fixed order, skipping whatever the header states — so the header tells you which columns a tail can hold and the order tells you which is which. Numbers in a tail are written as strings. - The triple is assembled, not printed. The repository comes from the header
(or a group’s own
repo), the path from the group’sfile, the qualified name and line from the label. That is the selector every tool accepts, so no row needs a stable key.
The other two spell a row as an object, for the same reason: their answer is not
a set of files. find_symbol addresses a
declaration with at, which is path:line under a header that names the
repository and the whole repository:path:line triple when the rows come from
more than one. find_cross_repo_consumers
keeps a field per consumer — repo, pkg, at — because a package dependency
has a repository and no file at all.
When a page groups
Section titled “When a page groups”A header only states a column when every row of the page agrees on it. One
dissenting row is enough to knock a column back down to the rows — a 65-row
page sharing kind and edge_kind pays for both on every row if row 66 is a
re-export the other 65 are not. groups is the second tier that catches this:
the six query tools that carry a compact view — find_symbol,
find_references, trace_dependencies, get_blast_radius,
get_file_outline and find_cross_repo_consumers — also try grouping the page
by whatever exact tuple of the remaining columns each row still shares, instead
of stating that tuple over and over on every row.
results.groups replaces the flat field — symbols, files or consumers,
depending on the tool — it never sits beside it. Each entry is a small header of
its own, holding the columns that tuple fixed, followed by the tool’s normal rows
in their normal shape: a label list under files for the four that group by
file, a symbols array for find_symbol, a consumers array for
find_cross_repo_consumers. A column already stated on the page header is never
repeated on a group; a column that still disagrees within a group stays on the
row, exactly as it would on a flat page.
Grouping is a bet, and the response never assumes it pays off: building both the
flat page and the grouped one and keeping whichever serializes smaller is cheap
next to a snapshot lookup, and it is the only way to guarantee grouping never
costs more than not grouping. A page where every row disagrees with every other
— three hops down three different edges is the ordinary shape of
trace_dependencies — stays flat, because a group is an object with its own
keys and a row with nothing to share only had to pay for its own values. Neither
shape changes total, returned or coverage; it only changes how the same
rows are spelled.
A find_symbol search for handle across two repositories, three kinds and no
column the whole page agrees on:
{ "exported": false, "groups": [ { "kind": "method", "symbols": [ /* 6 rows */ ] }, { "kind": "variable", "symbols": [ /* 1 row */ ] }, { "kind": "func", "symbols": [ /* 1 row */ ] } ]}exported is false for the whole page — nothing here is exported — so it
stays in the header; kind disagrees, so each group states its own and never
repeats it on a row. Every tool’s own reference page shows the full, unabridged
capture:
find_symbol,
find_references,
trace_dependencies,
get_blast_radius,
get_file_outline and
find_cross_repo_consumers.
Read the answer
Section titled “Read the answer”Every query tool returns the same envelope around its results. No tool
publishes an outputSchema, so the answer arrives once, in the text block, and
is not repeated as structured content.
| Field | What it says | In a compact envelope |
|---|---|---|
snapshot_id | The published generation that answered. null means none is published. | Always |
total | How many rows the query matched. | Always |
returned | How many are in this page. | Always |
results | The answer itself. | Always |
truncated | Whether returned is less than total. | Only when it is true |
next_cursor | The opaque token for the next page. | Only when there is one |
coverage | Four disjoint counters over the whole answer, not this page: exact, candidate, unresolved_related, package_level. | Only the counters above zero, and absent entirely when all four are zero |
snapshot_age_ms | How long ago that generation was built. | Never; ask graph_status for the age |
guidance | Present only when the count alone would mislead. | Unchanged |
completeness | Present only when the tool checked how far its answer reaches. | Unchanged |
What the compact envelope leaves out is what carried no information: an age
nobody asked for, a truncated that is false, a cursor that does not exist and
a category that counted nothing. Read an absence accordingly — a missing
truncated is false, a missing next_cursor is null, a missing
unresolved_related is 0 — and note that snapshot_id, total, returned
and results are always there, so the four numbers that say how much of the
answer you are holding never move. The full view writes every field, false,
null and zeros included.
coverage.package_level is counted apart from exact on purpose: a package
dependency proves the consumer depends on the provider package, never that it
uses the symbol you asked about, so folding it into exact would report a use
nobody observed.
The counters describe resolved relations, so a tool that returns no relations
carries none of them. exact and candidate are edge confidences, and the
scope is the answer rather than the page: the trace_dependencies envelope
below reports exact at 37 while returned is 3. That is what makes the
pair worth reading — a counter equal to returned could not tell you anything
returned had not already said.
So get_file_outline publishes no counter at all, and find_symbol publishes
only unresolved_related: both answer with declarations of one repository, and
a declaration has no confidence to report. get_source keeps a count of its
own and it is not one of these four: it answers in prose, and its header line
says how many bodies it could actually serve, which is genuinely less than
returned when a file moved under the index.
Three rules matter more than the rest.
A zero-result answer means “nobody”, not “not found” — but only when the
surface says so. That is what guidance is for. This is the captured answer
to find_cross_repo_consumers on MergeAll, total 0:
no repository in the published graph consumes this symbol. Check graph_status if a consumer is registered but was not indexed, and find_references for uses inside its own repositoryAn empty find_references page carries the same kind of sentence, and which one
depends on its verdict: with COMPLETE it says the edges are type-checked and
this is an absence rather than a miss, and with LOWER_BOUND it says the index
recorded places it could not read that ask for this name, and sends you to
completeness.blind_spots and the fallback pattern instead. The two are never
interchangeable: the first is a claim about the code, the second about the index.
Without either sentence, treat a zero only as far as the tool’s own description
takes you.
guidance stays silent when there are rows and no truncation. It costs
about fifteen tokens, and fifteen tokens of advice on every call is how a saving
becomes a cost. Its absence is not a warning that something is wrong; it means
the numbers speak for themselves.
A completeness verdict of LOWER_BOUND means the answer is a floor.
COMPLETE means nothing the index recorded could add to it. LOWER_BOUND means
the index recorded places it could not read that this query reaches, and it
names them: invisible_scopes for whole packages or modules that could not be
read, blind_spots for individual references the resolver could not follow, and
fallback for the recovery action, a regular expression and the paths to run it
over. Absence of the whole block means the tool did not check, which is not the
same as checking and finding nothing.
Six tools check, and what bounds an answer is not the same question for all of them, so each one looks at a different set of recorded failures:
| Tool | What can bound its answer |
|---|---|
find_references | Failures that asked for this name, plus unreadable scopes of the subject’s repository. direction does not change the verdict: it is charged with the naming question either way. |
get_blast_radius | The same pair, taken from the repository the walk starts in. |
trace_dependencies | Failures this symbol itself made, plus unreadable scopes of its repository. “What does this reach” is never bounded by who asked for its name — somebody else’s unreadable call hides a caller, not a dependency. |
find_symbol | Unreadable scopes. Narrowed with repo, only that repository’s; unnarrowed, every one in the graph. |
get_file_outline | Unreadable scopes of the repository asked for. There is no symbol name here, so nothing else can bound it. |
find_cross_repo_consumers | Unreadable scopes anywhere in the graph, deliberately. A package nobody could read in any repository is exactly where an outside consumer hides. |
The scope follows the question for a reason: a verdict charged for every blind
spot in the graph would read LOWER_BOUND on every answer of a corpus with one
bad package, and a verdict that never says COMPLETE carries no information.
The other five tools do not check, and none of them claims an absence.
get_symbol and get_source refuse a symbol they cannot find instead of
answering an empty list; graph_status, list_repositories and index_project
answer about the index itself.
This is the completeness block captured from get_blast_radius on MergeAll
at depth 2:
{ "verdict": "LOWER_BOUND", "invisible_scopes": [ { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-delta-profile", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile" }, { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-recovery", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@astrojs/node" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@astrojs/starlight" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@tailwindcss/vite" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "astro" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "vitest" } ], "fallback": { "pattern": "\\bMergeAll\\b", "paths": [ "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile", "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" ] }}Read that as: the five rows are real, two Go directories were excluded by build
constraints and five TypeScript package names had no provider in the graph, and
if the answer has to be airtight, grep \bMergeAll\b over the two paths named.
One response enumerates at most twenty entries per list; when the list is cut,
more_invisible_scopes and more_blind_spots carry the exact remainder.
Paginate
Section titled “Paginate”A page is not the whole answer. When truncated is true, total is the real
count and next_cursor is how you get the rest.
This is trace_dependencies on MergeAll at depth 1 with limit 3, envelope
only:
{ "snapshot_id": 30, "total": 37, "returned": 3, "truncated": true, "next_cursor": "Ah4DYbLgOYhUrECElsmz0oFlyLUGpgE", "coverage": { "exact": 37 }, "guidance": "showing 3 of 37; narrow with depth, max_nodes, edge_kinds or confidence, or pass the cursor for the next page"}Three of thirty-seven. Concluding anything about the other thirty-four from this page is a mistake the envelope told you not to make.
The token is base64url without padding, and it is opaque: do not parse it, do
not build one. It is about 31 characters over a binary body — a format version,
the snapshot id it was taken against, the row offset, a digest of the query
identity, a digest of the sorting contract and a checksum over all of it — where
the previous version spent 314 characters of base64-wrapped JSON, 221 tokens on
every truncated page. The query identity covers the tool name and every argument
that can affect which rows match or in what order, so the next call must repeat
the original arguments unchanged and add cursor. view, limit and
response_format are not among them.
| What changed | What happens |
|---|---|
| A newer generation was published | CURSOR_SNAPSHOT_EXPIRED; start the query again from page one. |
| Any argument affecting membership or ordering | CURSOR_INVALID, the cursor does not match the active query. |
| The tool’s sorting contract | CURSOR_INVALID. |
| The cursor was edited, truncated or re-encoded, or decodes with trailing bytes after the checksum | CURSOR_INVALID; the checksum covers the body. |
| The cursor was minted by a server of the previous cursor version | CURSOR_INVALID; the body declares version 2, and a version-1 token fails closed rather than being read under the new layout. |
The guidance names narrowing before paging, and in that order for a reason: a
second page of rows you did not want is a second payload. Cut with depth,
max_nodes, edge_kinds, confidence, repo or language first, and page
only when the narrowed answer is still too large.
Confidence and provenance
Section titled “Confidence and provenance”Every edge carries a confidence and a provenance, and neither view ever drops
them: in the compact one they sit in the header when the whole page agrees and on
the row when it does not, and in the full one they are on every row. Traversal
rows name the edge they arrived by as via_confidence and via_provenance. This
is a captured find_references row on MergeAll in the full view; the compact
page that carries the same edge states the last three fields once above it, and
spells the row as ["mergeSets@681-712", "func"]:
{ "name": "mergeSets", "qualified_name": "mergeSets", "kind": "func", "repository": "kivgraph", "file_path": "internal/indexer/full.go", "start_line": 681, "end_line": 712, "language": "go", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL"}confidence is one of EXACT_TYPECHECKED, EXACT_DECLARATION_MAPPED,
EXACT_PACKAGE_MAPPED, STRUCTURAL_CERTAIN, CANDIDATE or UNRESOLVED.
provenance says which analysis observed it: GO_AST_CALL and GO_TYPES_USE
come from the Go analysis, TYPESCRIPT_CHECKER from the TypeScript checker,
TYPESCRIPT_PROJECT_REFERENCE from a provider’s own build configuration.
An EXACT edge requires sufficient evidence and the correct provenance. It is
never created by a coincidence of name, text, path or alias, and never because
one candidate happened to be the only one left. That is the property that makes
an empty find_references answer worth acting on.
CANDIDATE and UNRESOLVED are different results from EXACT, not weaker
versions of it. CANDIDATE is plausible and unproven. UNRESOLVED carries no
target identity at all: it records that a reference was seen and could not be
followed, with its reason, repository and language, and its file and position
when there is a concrete occurrence. Neither is an edge you can treat as a use.
coverage.unresolved_related counts the unresolved records that touch your
query, which is the number that tells you whether the exact ones are the whole
story.
The Rust standard library
Section titled “The Rust standard library”The standard library of the toolchain that indexed the graph enters as a
synthetic derived repository named rust:<release>. That namespace is reserved,
so no registered repository can take the name, and the name is the only
authority needed to tell a derived row from yours. It has no commit, no branch
and no dirty flag: nothing clones it and nothing can move it.
It is withheld by default from the four served tools that could return one of its rows:
The default is the difference between a usable answer and an unusable one: with
the standard library in the graph, find_references on Clone or Debug
reaches most of the corpus, and a page of core is not what someone asking
about their own code wants.
Two things override it. include_derived: true, on any of the four. And naming
the derived provider in repo, on the three that take a repo filter, because
an explicit filter is a request and not an accident. get_blast_radius has no
repo argument, so there include_derived is the only way.
Withholding a row is a page decision and never a claim about what was observed.
The edge stays published with its exact confidence; it was simply not put on
this page. The counts stay honest too: graph_status breaks the derived
provider out under derived, with its own packages, files, symbols, inbound and
internal edges and unresolved references, and list_repositories marks its row
derived.
When not to use it
Section titled “When not to use it”A rare name in one small repository is cheaper to grep. Indexing a small file costs more than reading it. Kivgraph wins on common names, on transitive impact, on consumers in another repository and on proving an absence. Spending a call where grep would have done means paying for the graph twice: once for the call, once for the read you still make.
The repository carries a token-cost harness under benchmarks/mcp-token-cost,
and its numbers say the same thing. It compares two arms on the question “who
calls this symbol, and what do those callers look like”: the host’s own captured
answer, grep plus the file reads that follow it, against the MCP calls a session
needs against the published generation plus the same reads. Tokens are counted
with cl100k_base, over the kivgraph corpus at generation 000026, 14424
symbols across 363 files.
Measured on the answer alone, the part a graph server owns. These factors were taken against the previous output shape, before the compact view became the default, so they are a ceiling on what a session pays today:
| Symbol | Class | Factor against the native arm |
|---|---|---|
MergeAll | rare name | 1.20x |
CanonicalColumns | rare name | 2.24x |
DiscoverGo | rare name | 1.74x |
BuildPlan | shared name | 4.35x |
NewServer | common name | 6.00x |
Publish | common name | 5.52x |
| Total | 3.46x |
Counting the whole session, the answer plus the bodies the agent then opens, which both arms pay identically, the total factor is 1.42x, and 15102 of the 25768 native tokens are source bodies. The report publishes both figures on purpose: the answer factor flatters the graph and the session factor flatters grep.
The cross-repository question was measured separately, on Compute in a
three-file shared-library corpus, and there the MCP arm cost more: 0.53x on
the answer and 0.70x on the session. The harness records why that column is a
floor rather than a ceiling for the native side: a grep finds the name but
cannot tell whether the hit is the same symbol, and says nothing about a
consumer that depends on the provider package without using the symbol. Neither
latency nor money is measured anywhere in the harness.
Keep the graph fresh
Section titled “Keep the graph fresh”Answers come from the published generation, not from your working tree. A file you edited a minute ago is not in the graph until a rebuild publishes it.
graph_status is how you find out. Its
repository_freshness block lists each repository with the commit it was
indexed at and the commit its working tree is on now, and repositories_moved
counts the ones that left the indexed commit. A repository whose HEAD could not
be read is not counted as moved and not silently counted as fresh either.
Rebuilding is kivgraph index --full from the CLI, or
index_project from the client. The tool
requires explicit user approval before it runs: called without confirmed, it
returns PERMISSION_REQUIRED. A rebuild costs the whole corpus, so pass every
project in one call. See /guides/indexing/ for the full
procedure.