MCP tool contract
The contract between the duckdb-code MCP server and the IDE, CLI, or agent that consumes it. It defines the target tool set, what each tool returns, and — deliberately — what the server does not do because a Language Server already does it better. This is the roadmap for the tool surface; each tool's implementation is tracked as a follow-on issue, and the data every tool reads is defined by the indexing strategy — the two are co-designed and must stay consistent.
- Division of labour with LSP
- The shared result contract
- Target tools
- Tool-to-data map
- From today's four tools to the target set
- Non-goals
- Build order
Division of labour with LSP
A Language Server is authoritative and live: it answers "what is at this exact cursor position?" over the unsaved buffer. duckdb-code is the opposite kind of tool — a whole-repo, precomputed, multi-language, history-aware snapshot that answers "where in the repo should I look, and what is at stake if I change this?". The two are complementary, and the contract stays strictly on the side LSP cannot cover:
- Whole-repo, not point queries. "Where do we retry failed payments?" and "what breaks if I change this?" span the repository; LSP is driven by one cursor position.
- One call, not many hops. Transitive impact is precomputed, so a blast radius is one tool call, not dozens of interactive call-hierarchy round trips.
- Cross-language and cross-boundary. Edges an LSP never sees — an HTTP route to its client in another language, a config key to the code that reads it — are first-class here.
- History-aware. Git co-change and churn are structural signals no live language server carries.
Every result carries path + range, so the agent can hand a precise follow-up straight to the LSP. The MCP answers where and what's at stake; the LSP answers what exactly is here.
The shared result contract
Every tool returns a single JSON object. Every symbol or location in a result carries, at minimum:
path— repository-relative file path.range— start/end line (and column where available), so the consumer can fetch the exact text from the live buffer or hand off to the LSP.id— a stable symbol id (the graph node id), usable as thesymbolargument to other tools.resolution—exactorbest_effort, present wherever a binding is heuristic, so a cross-boundary or suffix-matched edge is never silently presented as certain.
resolution is the agent-facing projection of the numeric per-edge confidence that the indexing strategy stores as the single source of truth (for example exact at or above 0.9, best_effort below, by the threshold documented there). The raw confidence may also be surfaced. There is one scoring concept, defined in the schema and projected here — never two that can drift.
Target tools
The target surface is ten tools. Parameters and the shape each returns:
search(query, limit=…)
Intent / ranked-symbol search — "where do we retry failed payments?" when you do not know the name. The single biggest gap LSP leaves. Returns {query, results: [{id, path, range, kind, qualified_name, score}]}, ranked by semantic relevance.
overview(budget)
The repo map an agent reads to orient itself before touching anything: modules, entry points, and the most central symbols, ranked by centrality and sized to a token budget. Returns {modules: […], entry_points: [{id, path, range}], central_symbols: [{id, path, range, rank}], budget}.
impact(symbol, depth=…, limit=50)
Transitive reverse-dependency set — "what breaks if I change this?" — in one call, aggregated by module with the affected entry points and the involved tests. Returns {target, resolved: [node], ambiguous, affected: [{node, distance, resolution, via_container}], affected_by_module: {…}, affected_entry_points: […], related_tests: […], affected_files: […], limit, truncated, total_affected, total_affected_files, total_affected_modules, total_affected_entry_points, total_related_tests, total_direct}.
A method target also seeds its class, module, and file, so one call answers both "who calls this method" and "what breaks if its class changes". via_container tells them apart: it is false for a direct dependent of the target and true for a node that depends only on one of its containers. affected lists the direct dependents first, and total_direct counts them.
The response is bounded so a high fan-in target cannot overflow the MCP transport. limit (default 50) caps each list: the direct, then nearest, affected nodes, the most-affected modules in affected_by_module (with their full counts) and their affected_files, the first entry points and the first related tests. The total_* fields carry the uncapped counts, and truncated is true when anything was cut. Raise limit, or narrow with depth, to see more. The CLI impact verb and the Python CodeStore.impact() are unbounded by default.
links(symbol)
Cross-boundary edges LSP never sees together: HTTP route↔client, message-queue producer↔consumer, DI bindings, config/env keys↔readers, SQL tables↔queries and ORM models, feature flags. Returns {symbol, links: [{kind, from, to, path, range, resolution}]} with each edge's resolution/confidence.
related_tests(symbol)
What to run after an edit, by static reachability plus naming convention. Returns {symbol, tests: [{id, path, range, reason}]}.
A call on an object of unknown type, such as chunker.chunk_file(), binds by the method name to every class that defines it. Such a reach counts only when the test also reaches the method's class, and a file's import nodes are not part of the file, so a test of another chunker or of a shared model is not listed.
find_similar(symbol | snippet)
Code built the same way — "how do we usually do this here?" — so an agent follows house patterns instead of inventing new ones, and so duplication is visible. Returns {query, matches: [{id, path, range, score}]}.
history(symbol)
Git signals: recent commits touching the symbol, co-change partners (files that usually change with it), and churn. Returns {symbol, commits: […], co_change: [{path, together_count}], churn}.
together_count is the number of mined commits that touched both files, and churn the number that touched the file: both are counts from git, within the last 500 commits of the file's repository, leaving out sweeping commits that touched more than 50 indexed files. Partners are ranked by coupling normalized within their repository. Markdown pages take part like code files, so a changelog or a guide that moves with the code shows up as a partner.
analytics(kind)
Whole-repo structural views for planning and review: dependency cycles, clusters/communities, dead_code candidates, and hotspots (complexity × churn). Returns {kind, results: […]} shaped per kind.
A cycle is a claim of certainty, so cycles uses only binds above suffix confidence (0.55): exact, import, same-module, and unique-name binds. A call bound by a name that several symbols share, such as close or embed, cannot close a cycle. self.method() binds exactly to the enclosing class's own method, so genuine mutual recursion between methods is still reported.
context_for_change(diff | branch)
The touched symbols plus their graph neighbourhood — the right primitive for code review and PR-description work. Returns {changed: [{id, path, range}], neighbourhood: [{id, path, range, relation}]}.
On a root holding several git repositories, each repository is diffed and the paths are rebased onto the index; a base ref is applied to every repository that has it, and the others are skipped.
index_status()
Freshness: the indexed commit and whether the working tree is dirty, read from the state the indexer records. Returns {indexed_commit, branch, dirty, indexed_at, node_count, edge_count, repositories: [{path, branch, commit, dirty}]}.
A single-repository root lists no repositories. A root holding other repositories, such as a workspace folder, lists each one with its own revision and dirty flag, and the top-level dirty sums them up: true when any is dirty, false when all are clean, null when any is unknown.
Tool-to-data map
Each tool reads a data source the indexing strategy defines; the schema must provide every one, and no source should be orphaned:
| Tool | Data source (see indexing strategy) |
|---|---|
search | per-symbol embeddings / vector storage |
find_similar | per-symbol embeddings / vector storage |
overview | index-time centrality + the core node/edge graph |
impact | the core node/edge graph |
links | cross-boundary / route edges (with confidence) |
related_tests | TESTS edges |
history | git commit + co-change data |
analytics | centrality + union-find communities (member_of) |
context_for_change | the core graph + git diff/branch data |
index_status | the indexed commit + content hash the indexer records |
The sharpest dependency is search / find_similar: they need embeddings the current structural schema does not carry, which the indexing strategy's vector decision provides. No tool is specified here without a data source there.
From today's four tools to the target set
The server ships four tools today; the move to the target set is additive, not a rewrite:
| Today | Fate | Becomes |
|---|---|---|
impact | Keep + extend | aggregate affected by module, add entry points and related tests |
explain | Folded | dropped from the MCP surface (structural neighbourhood now covered by overview/context_for_change); kept on the SDK and CLI |
report | Reshape | split into index_status (freshness) and overview (repo map) |
export | Keep | the whole-graph JSON dump, unchanged |
| — | Add | search, links, related_tests, find_similar, history, analytics, context_for_change |
Non-goals
The contract deliberately does not provide definition, references, hover, rename, or diagnostics. These are point queries an LSP answers authoritatively over the live buffer, while this index is a lagging commit-snapshot — duplicating them would return wrong-because-stale answers and bloat the index. An agent that needs them uses the path + range in a result to hand off to the LSP.
Build order
The tools land by value, each as a follow-on implementation issue referencing this contract and blocked on the indexing strategy element it reads. search and find_similar have shipped (both ranked over the vectors table); the rest follow:
- MVP (most value for a small build):
search(shipped),overview(shipped),impact(shipped, extended with per-module blast radius, entry points, and related tests),related_tests(shipped),history(shipped). - Differentiators (the gap nobody covers well):
links(shipped),find_similar(shipped). - Remaining:
analytics(shipped),index_status(shipped),context_for_change(shipped);explainfolded off the MCP surface (kept on the SDK/CLI);reportreshaping still open.
Each follow-on issue is scoped to one primitive (or a small group that shares a data source) so its PR stays reviewable; search, find_similar, and history pull in new data sources (embeddings, git) that the indexing strategy must land first.