Status
Accepted
Date
2026-05-02
Context
Three sibling files in crates/codelens-mcp/src/tool_defs/ carry roughly
3,000 lines of declarative metadata:
| File | Lines | Role |
|---|---|---|
build.rs |
~250 (excluding the registry list itself) | Tool registration with Tool::new(name, description, input_schema_json, ...). Hand-rolled. |
presets.rs |
1,470 | Static &[&str] arrays per profile, per preset, plus tool_deprecation, tool_namespace, tool_tier, tool_preferred_executor_label, tool_anthropic_search_hint, tool_anthropic_always_load. Hand-rolled. |
output_schemas.rs |
1,484 | One fn <tool_name>_output_schema() -> Value { json!({...}) } per tool that ships an output schema. Hand-rolled. |
Adding a single new tool currently requires synchronised edits in four places:
- handler in
tools/<module>::<fn> - row in
build.rs(input schema, annotations, max_response_tokens) - one or more profile/preset arrays in
presets.rs - optional output schema in
output_schemas.rs
ADR-0011 §Neutral / Deferred flagged this as a structural-sprawl item. The 2026-05-02 audit measured the resulting drift surface:
- README and
docs/architecture.mdboth publish aWorkspace members:count that gets out-of-sync withCargo.tomlbecause both numbers are hand-typed (already fixed in PR #125, but the same drift class re-applies to tool counts). - The
surface-manifest.pyscript already attempts to reconcile README + architecture.md from a JSON snapshot, but the JSON itself is hand-maintained. PR #125 had to rewrite it manually. - Of the 112 registered tools, 82 carry an output schema and 30 do not, and the only way to audit which is which is to grep two separate files.
- The PR #125 sprawl audit added
#[deprecated]metadata to three composite tools, which required edits inpresets.rsonly. Future additive metadata (e.g., a per-tool retrieval-tier hint) would fan out across all three files.
ADR-0011 also queued this work behind ADR-0012 (semantic feature default-off, PR #127) so we ship behaviour changes one at a time.
Decision
Introduce a single source-of-truth file, crates/codelens-mcp/tools.toml,
that drives generated Rust modules through a Python regenerator script.
Five sub-decisions follow.
1. Source-of-truth format → TOML
# crates/codelens-mcp/tools.toml (excerpt)
[[tool]]
name = "read_file"
category = "file_io"
description = "[CodeLens:File] Read file contents with optional line range."
handler = "filesystem::read_file_tool"
annotations = "ro_p"
input_schema = { required = ["relative_path"], type = "object", properties = { relative_path = { type = "string" }, start_line = { type = "integer" }, end_line = { type = "integer" } } }
output_schema = "file_content_output_schema"
presets = ["minimal", "balanced", "full"]
profiles = ["planner-readonly", "builder-minimal", "reviewer-graph", "refactor-full", "ci-audit", "workflow-first"]
namespace = "file"
tier = "primitive"
execution_class = "read"
risk = "low"
cost_hint = "low"
concurrency_safe = true
Considered alternatives:
| Option | Reason rejected |
|---|---|
| JSON5 | Comments are good but TOML is already used heavily in this repo (Cargo.toml, release-plz.toml, .codelens/bridges.json is the only JSON exception). One less syntax to learn for the reviewer. |
build.rs + Rust DSL |
Compile-time generation breaks IDE jump-to-definition: rust-analyzer cannot follow into OUT_DIR files. The whole point of this ADR is to keep the generated artefact greppable. |
declarative macro (tools! { ... }) |
rust-analyzer support for token-tree-heavy macros is uneven, errors point at the macro call site rather than the declaration line, and the macro source itself becomes the source-of-truth — solving nothing. |
| JSON | No comments. Tool definitions need rationale comments next to deprecation / experimental flags. |
| YAML | Significant-whitespace surface area on a 100+ tool registry is a maintenance hazard. |
TOML is also natively parseable by tomllib (Python 3.11+), avoiding a
new dependency in the regen script.
2. Generation timing → committed generated files + CI drift check
crates/codelens-mcp/
├── tools.toml # source of truth (hand-edited)
└── src/tool_defs/
├── build.rs # imports build_generated::TOOLS
├── presets.rs # imports presets_generated::*
├── output_schemas.rs # imports output_schemas_generated::*
└── generated/
├── build_generated.rs # produced by regen script, committed
├── presets_generated.rs # produced by regen script, committed
└── output_schemas_generated.rs # produced by regen script, committed
Considered alternatives:
| Option | Reason rejected |
|---|---|
build.rs to write into OUT_DIR |
Loses jump-to-definition. Reviewers cannot grep the actual generated lines in PR diffs. Adds a Python build-time dependency to every consumer of the crate. |
Generate at cargo install time |
Same reviewer ergonomics problem and shifts work to end users. |
Trade-off: the generated files take real space in the repo and clutter
PR diffs, but the diffs are deterministic — a reviewer can verify drift
against the TOML in seconds. CI runs python3 scripts/regen-tool-defs.py
--check after cargo fmt --check; a mismatch fails CI before any test
or clippy run, with a clear "run regen-tool-defs.py --write" message.
3. Schema validation → JSON Schema for tool I/O + Rust struct for codegen contract
The input_schema and output_schema fields embedded in TOML are
already JSON Schema (the MCP wire format). The regen script validates
each tool.input_schema against the JSON Schema meta-schema before
emitting Rust, catching typos early.
The TOML structure itself is described by a ToolDef Rust struct
(crates/codelens-mcp/src/tool_defs/codegen.rs) that the regen script
mirrors exactly. The generator emits assert_eq!(toml_schema_version,
EXPECTED_VERSION) so a TOML schema bump is impossible to ship without
a corresponding Rust change.
4. Migration → incremental, one tool category per PR
Migrating 112 tools in one PR would be an unreviewable wall of generated
code. Instead, each migration PR moves one of the eight categories
(File I/O, Symbol, LSP, Editing, Composite, Session, Memory, Semantic)
to TOML and verifies that the tools/list output is byte-identical to
the previous build:
PR-A: scaffolding + File I/O — 7 tools ✓ #128
PR-B: Symbol + LSP — 16 tools ✓ #129
PR-C: Editing + Analysis — 25 tools ✓ #130
PR-D: Composite (excluding workflow-first 7) — 22 tools ✓ #131
PR-E: Workflow-first 7 + Session 23 — 30 tools ✓ #132
PR-F: Memory 5 + Rule corpus 1 + Semantic 6 (final) — 12 tools ✓ this PR
Status as of PR-F merge: every tool in the static TOOLS registry
is declared in tools.toml. build.rs retains only the
annotation-binding locals (ro_p, ro_a, ro_w, mut_p, mut_w,
mutating, destructive, dest_a, mut_coord) consumed by the
generated category functions, plus the post-build pass that attaches
namespace, title, and estimated_tokens. The semantic category is
emitted under #[cfg(feature = "semantic")] via an opt-in
feature_gate = "semantic" field in TOML — codegen detects when every
tool in a category shares a feature gate and lifts it onto the
generated function. The original tool counts (15 for Editing+Analysis
in the plan) under-counted the unified-edit and refactor entries; the
actual migration was 25 tools in PR-C. All other PR sizes match.
Considered alternative: atomic migration (single PR). Rejected because (a) review is impossible, and (b) git bisect on tool-related regressions would be useless if every breaking change is hidden inside one giant diff.
5. Per-tool metadata location → inline in TOML
All metadata about a tool — input schema, output schema function name,
profile/preset membership, namespace, tier, deprecation, and host-neutral
execution policy — lives inside the same [[tool]] table.
Considered alternative: separate [deprecations] / [profiles] /
[presets] tables in TOML, mirroring the current presets.rs shape.
Rejected because it reproduces the same fan-out problem one level up:
adding a tool would still require synchronised edits to multiple TOML
sections.
The trade-off is that profile/preset arrays in presets_generated.rs
are reconstructed on the fly (the regen script collects all
tool.profiles = [...] entries and inverts the index). The script's
output is deterministic so reviewers see one canonical preset/profile
list per build.
Out-of-scope sub-decisions
- Per-language input-schema validation. JSON Schema validates structure but not domain semantics. Out of scope.
- Custom
tool_anthropic_search_hintper-host overrides. Currently hand-coded; the migration carries the existing values forward unchanged. Future host-specific overrides come as a separate ADR. - Auto-generation of
surface-manifest.json. That JSON has its own generator (scripts/surface-manifest.py); coupling the two is a follow-up. - Migration of
cli/hostentrypoints, prompts, or resources. Same declarative pattern would help, but out of scope.
Consequences
Positive
- Adding a new tool reduces from 4-file synchronised edits to a single
[[tool]]table append +regen-tool-defs.py --write. - CI drift check makes accidental presets/schemas/build.rs divergence impossible to merge.
- Reviewers can read
tools.tomlas a single overview of the surface; generated Rust files become "machine output, do not edit" with a prominent banner. - Future additive metadata (e.g., per-tool retrieval-tier hint, latency budget) lands as one TOML field plus one regenerator change, not three file edits.
- Per-PR migration boundary creates clean revert points if any category surfaces an unexpected divergence.
Negative
- Repo carries roughly 3,000 lines of generated code in version control for as long as the migration is incomplete (six PRs over ~2 weeks). Mitigation: each PR's diff makes the generation difference visible, so the human edit (TOML) and the machine output (Rust) review side by side.
- New contributor needs to run a Python script to add a tool. Mitigation:
tools.tomlaccepts manual edits and the script's--checkmode produces a one-line "what to run" hint when CI fails. tools.tomlbecomes the second-largest file in the crate (estimated ~2,000 lines once fully populated). Mitigation: TOML supports comments and section headers; the file is structurally regular so search and grep stay fast.
Neutral / Deferred
- The hand-rolled
presets.rsretains itstool_anthropic_search_hint,tool_anthropic_always_load, andis_tool_in_profilepredicate helpers. The migration replaces only the static data tables; the helper functions are unchanged. - The
output_schemas.rsfn <name>_output_schema()pattern stays; the regenerator emits the same shape underoutput_schemas_generated.rsandoutput_schemas.rsre-exports them. This preserves call-site compatibility withbuild.rsrows that reference the function name. - ADR-0014 (AppState decomposition, Track 3) starts after this ADR's
PR-A through PR-F are merged. The final shape of
tools.tomlis expected to surface state-access metadata that informs that work.
Cross-reference
- ADR-0011 — control-plane sprawl resolution; this is Track 2 of the §Neutral / Deferred follow-up roadmap.
- ADR-0012 (PR #127) — semantic-feature default-off; this PR stacks on top of it.
scripts/surface-manifest.py— companion generator for README +docs/architecture.md+docs/generated/surface-manifest.json. The two generators do not yet share infrastructure; tracked as a follow-up.