Skip to content

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.

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.

The questionThe tool
Who calls this, what references thisfind_references
What breaks if I change thisget_blast_radius
What does this reach outwardtrace_dependencies
Who uses this from another repositoryfind_cross_repo_consumers
Where is this declaredfind_symbol
I do not know its name; which files do I openfind_by_intent, with keywords
What are this symbol’s package, signature, visibility and line rangeget_symbol
What is declared under this pathget_file_outline
Give me the code of these symbolsget_source
Which repositories does the graph cover, at which commitlist_repositories
Is the published graph currentgraph_status
Register projects and rebuild the graphindex_project

index_project is the only tool that changes anything. Every other tool is annotated readOnlyHint.

Every tool that takes a symbol accepts one of two selectors:

  • stable_key, or
  • the triple repository plus repository-relative path plus qualified_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 graph

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.

viewWhat you getWhere
compactThe 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
fullThe 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
filesOnly 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, or qualified_name@start-end when 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’s file, 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.

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.

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.

FieldWhat it saysIn a compact envelope
snapshot_idThe published generation that answered. null means none is published.Always
totalHow many rows the query matched.Always
returnedHow many are in this page.Always
resultsThe answer itself.Always
truncatedWhether returned is less than total.Only when it is true
next_cursorThe opaque token for the next page.Only when there is one
coverageFour 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_msHow long ago that generation was built.Never; ask graph_status for the age
guidancePresent only when the count alone would mislead.Unchanged
completenessPresent 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 repository

An 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:

ToolWhat can bound its answer
find_referencesFailures 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_radiusThe same pair, taken from the repository the walk starts in.
trace_dependenciesFailures 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_symbolUnreadable scopes. Narrowed with repo, only that repository’s; unnarrowed, every one in the graph.
get_file_outlineUnreadable scopes of the repository asked for. There is no symbol name here, so nothing else can bound it.
find_cross_repo_consumersUnreadable 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.

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 changedWhat happens
A newer generation was publishedCURSOR_SNAPSHOT_EXPIRED; start the query again from page one.
Any argument affecting membership or orderingCURSOR_INVALID, the cursor does not match the active query.
The tool’s sorting contractCURSOR_INVALID.
The cursor was edited, truncated or re-encoded, or decodes with trailing bytes after the checksumCURSOR_INVALID; the checksum covers the body.
The cursor was minted by a server of the previous cursor versionCURSOR_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.

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

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:

SymbolClassFactor against the native arm
MergeAllrare name1.20x
CanonicalColumnsrare name2.24x
DiscoverGorare name1.74x
BuildPlanshared name4.35x
NewServercommon name6.00x
Publishcommon name5.52x
Total3.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.

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.