lean_ctx/server/tool_visibility.rs
1//! Pure tool-visibility policy for the MCP `tools/list` response.
2//!
3//! Extracted from the (async, server-bound) `list_tools` handler so the policy
4//! is unit-testable in isolation. The handler resolves the candidate set
5//! (lazy-core vs profile-authoritative vs full registry) and the per-call gates
6//! (role, workflow), then defers to these helpers for the stable rules:
7//! * Internal/meta tools are never advertised.
8//! * The active profile, `disabled_tools`, and the per-client
9//! [`ClientQuirks`] (Zed `ctx_edit`, native-editor `ctx_patch`) filter the
10//! candidates.
11//! * The universal invoker (`ctx_call`) is force-advertised in non-full mode so
12//! tools hidden by lazy/profile filtering stay reachable.
13
14use super::dynamic_tools::{ToolCategory, categorize_tool};
15use crate::core::tool_profiles::ToolProfile;
16
17/// The universal invoker tool name. A static-list MCP client can call any
18/// registered tool through it, even when that tool isn't advertised.
19pub const INVOKER: &str = "ctx_call";
20
21/// Which candidate pool `tools/list` starts from, before per-tool gates run.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CandidateSet {
24 /// Full registry (`LEAN_CTX_FULL_TOOLS=1` / `LEAN_CTX_LAZY_TOOLS=0`).
25 Full,
26 /// Consolidated unified surface (`LEAN_CTX_UNIFIED`).
27 Unified,
28 /// The user pinned a profile — it is authoritative and resolves against
29 /// the full registry (#358), so `standard` advertises its complete set.
30 ProfileAuthoritative,
31 /// Lean default: only `CORE_TOOL_NAMES` are advertised; everything else
32 /// stays reachable through [`INVOKER`] (#575).
33 LazyCore,
34}
35
36/// Decides the candidate pool. Single source of truth for the `tools/list`
37/// handler AND offline measurement (`doctor overhead`), so the advertised
38/// surface and the reported overhead can never drift apart.
39#[must_use]
40pub fn candidate_set(full_mode: bool, unified_env: bool, explicit_profile: bool) -> CandidateSet {
41 if full_mode {
42 CandidateSet::Full
43 } else if unified_env {
44 CandidateSet::Unified
45 } else if explicit_profile {
46 CandidateSet::ProfileAuthoritative
47 } else {
48 CandidateSet::LazyCore
49 }
50}
51
52/// Whether the user explicitly pinned a tool profile (config key, custom tool
53/// list, or env var) — the trigger for [`CandidateSet::ProfileAuthoritative`].
54#[must_use]
55pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
56 cfg.tool_profile.is_some()
57 || !cfg.tools_enabled.is_empty()
58 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
59}
60
61/// Client-specific advertising quirks, resolved once per `tools/list` from the
62/// MCP `clientInfo` name and the candidate set.
63///
64/// [`ClientQuirks::default`] (no quirks) is the "default client" used by
65/// offline measurement — the worst-case surface, nothing hidden.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub struct ClientQuirks {
68 /// Zed cannot handle `ctx_edit` (schema quirk) — hide it there.
69 pub hide_ctx_edit: bool,
70 /// Lazy-core only (#1008): the client ships a reliable native str-replace
71 /// editor, so the *default* surface skips `ctx_patch` — those sessions pay
72 /// zero extra schema tokens. A pinned profile is the user's explicit,
73 /// client-agnostic choice and always advertises its full set.
74 pub hide_ctx_patch: bool,
75}
76
77impl ClientQuirks {
78 /// Resolve the quirks for one `tools/list` answer.
79 #[must_use]
80 pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
81 let lower = client_name.to_lowercase();
82 Self {
83 hide_ctx_edit: lower.contains("zed"),
84 hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
85 }
86 }
87}
88
89/// Clients whose built-in edit tool is reliable enough that the default
90/// (lazy-core) surface need not advertise `ctx_patch`: Cursor, Zed,
91/// Windsurf/Codeium, Antigravity, OpenCode. Everyone else gets the anchored
92/// editor — Claude Code (the hook read-redirect breaks its native
93/// read-before-write guard, #637), CodeBuddy, pi/SDK harnesses and
94/// unknown/headless clients that have no native editor at all.
95fn has_native_editor(lower_client_name: &str) -> bool {
96 [
97 "cursor",
98 "zed",
99 "windsurf",
100 "codeium",
101 "antigravity",
102 "opencode",
103 ]
104 .iter()
105 .any(|c| lower_client_name.contains(c))
106}
107
108/// Decides whether a tool name should appear in `tools/list`.
109///
110/// `role_allows` is supplied by the caller (it depends on the active role, which
111/// is resolved outside this pure function). Internal tools are hidden
112/// unconditionally — they're invoked automatically or via [`INVOKER`].
113#[must_use]
114pub fn is_tool_visible(
115 name: &str,
116 profile: &ToolProfile,
117 disabled: &[String],
118 quirks: ClientQuirks,
119 role_allows: bool,
120) -> bool {
121 if categorize_tool(name) == ToolCategory::Internal {
122 return false;
123 }
124 // #509: deprecated read-cluster aliases (ctx_smart_read, ctx_multi_read) are
125 // hidden from the advertised surface but stay callable for one release.
126 if super::dynamic_tools::is_deprecated_alias(name) {
127 return false;
128 }
129 if !profile.is_tool_enabled(name) {
130 return false;
131 }
132 if disabled.iter().any(|d| d == name) {
133 return false;
134 }
135 if quirks.hide_ctx_edit && name == "ctx_edit" {
136 return false;
137 }
138 if quirks.hide_ctx_patch && name == "ctx_patch" {
139 return false;
140 }
141 role_allows
142}
143
144/// Computes the tool set this install advertises to a default client
145/// (no client quirks, no role restriction, no workflow gate, static tool list),
146/// including the live description compression. Offline counterpart of the
147/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
148/// kept next to the pure gates so measurement cannot drift from policy.
149/// "No quirks" is the worst case: a client without a native editor sees
150/// `ctx_patch` too, so the reported overhead never understates.
151#[must_use]
152pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
153 let cfg = crate::core::config::Config::load();
154 let disabled = cfg.disabled_tools_effective();
155 let profile = cfg.tool_profile_effective();
156 let full_mode = crate::tool_defs::is_full_mode();
157 let registry = crate::server::registry::build_registry();
158
159 let candidate = candidate_set(
160 full_mode,
161 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
162 explicit_profile(&cfg),
163 );
164 let pool: Vec<rmcp::model::Tool> = match candidate {
165 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
166 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
167 CandidateSet::LazyCore => {
168 let core = crate::tool_defs::core_tool_names();
169 registry
170 .tool_defs()
171 .into_iter()
172 .filter(|t| core.contains(&t.name.as_ref()))
173 .collect()
174 }
175 };
176
177 let mut tools: Vec<_> = pool
178 .into_iter()
179 .filter(|t| {
180 is_tool_visible(
181 t.name.as_ref(),
182 &profile,
183 &disabled,
184 ClientQuirks::default(),
185 true,
186 )
187 })
188 .collect();
189
190 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
191 if needs_invoker(full_mode, already, true, &disabled)
192 && let Some(def) = registry
193 .tool_defs()
194 .into_iter()
195 .find(|t| t.name.as_ref() == INVOKER)
196 {
197 tools.push(def);
198 }
199
200 let level = crate::core::config::CompressionLevel::effective(&cfg);
201 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
202 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
203 return tools;
204 }
205 tools
206 .into_iter()
207 .map(|mut t| {
208 let compressed = crate::core::terse::mcp_compress::compress_description(
209 t.name.as_ref(),
210 t.description.as_deref().unwrap_or(""),
211 mode,
212 );
213 t.description = Some(compressed.into());
214 t
215 })
216 .collect()
217}
218
219/// Whether the lazy per-category gate should filter the advertised tool set.
220///
221/// The dynamic-tools category gate (load tools on demand, signalled via
222/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
223/// surface small for capable clients. An explicit profile is the user's chosen,
224/// authoritative surface, so it must be advertised in full — otherwise category
225/// gating silently drops profile-enabled tools (e.g. Standard's
226/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
227/// advertised set stops matching `lean-ctx tools show` (#358).
228#[must_use]
229pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
230 supports_list_changed && !explicit_profile
231}
232
233/// Whether [`INVOKER`] must be force-added to the advertised set.
234///
235/// True only in non-full mode when it isn't already present, the role permits
236/// it, and it isn't explicitly disabled. In full mode every tool is already
237/// listed, so no gateway is needed.
238#[must_use]
239pub fn needs_invoker(
240 full_mode: bool,
241 already_present: bool,
242 invoker_role_allowed: bool,
243 disabled: &[String],
244) -> bool {
245 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 /// No client quirks — the default/measurement client.
253 fn no_quirks() -> ClientQuirks {
254 ClientQuirks::default()
255 }
256
257 #[test]
258 fn internal_tools_never_visible_even_in_power() {
259 // Power enables everything, but Internal/meta tools must still be hidden.
260 let p = ToolProfile::Power;
261 assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
262 assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
263 assert!(!is_tool_visible(
264 "ctx_discover_tools",
265 &p,
266 &[],
267 no_quirks(),
268 true
269 ));
270 }
271
272 #[test]
273 fn deprecated_aliases_never_visible_even_in_power() {
274 // #509: folded read-cluster aliases are hidden from tools/list in every
275 // mode (Power enables everything) — but stay registered + callable.
276 let p = ToolProfile::Power;
277 assert!(!is_tool_visible(
278 "ctx_smart_read",
279 &p,
280 &[],
281 no_quirks(),
282 true
283 ));
284 assert!(!is_tool_visible(
285 "ctx_multi_read",
286 &p,
287 &[],
288 no_quirks(),
289 true
290 ));
291 }
292
293 #[test]
294 fn deprecated_aliases_stay_registered_and_callable() {
295 // The non-breaking contract (#509): hidden from the advertised surface,
296 // but still in the registry so direct calls and ctx_call keep working
297 // for one release. Removal is Phase 2.
298 let _guard = crate::core::data_dir::isolated_data_dir();
299 let defs = crate::server::registry::build_registry().tool_defs();
300 for name in [
301 "ctx_smart_read",
302 "ctx_multi_read",
303 "ctx_semantic_search",
304 "ctx_symbol",
305 ] {
306 assert!(
307 defs.iter().any(|t| t.name.as_ref() == name),
308 "{name} must stay registered (callable) even though hidden"
309 );
310 assert!(
311 !is_tool_visible(name, &ToolProfile::Power, &[], no_quirks(), true),
312 "{name} must be hidden from tools/list"
313 );
314 }
315 }
316
317 #[test]
318 fn core_tool_visible_under_power() {
319 assert!(is_tool_visible(
320 "ctx_read",
321 &ToolProfile::Power,
322 &[],
323 no_quirks(),
324 true
325 ));
326 }
327
328 #[test]
329 fn standard_exposes_its_advertised_tools() {
330 // These are in STANDARD_TOOLS but were dropped by the old
331 // `core ∩ standard` intersection. Profile-authoritative resolution must
332 // surface them.
333 let p = ToolProfile::Standard;
334 assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
335 assert!(is_tool_visible("ctx_explore", &p, &[], no_quirks(), true));
336 assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
337 assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
338 // #1008: anchored editing ships with the pinned Standard profile.
339 assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
340 }
341
342 #[test]
343 fn folded_search_aliases_never_visible() {
344 // #509: ctx_semantic_search + ctx_symbol are consolidated into ctx_search
345 // (action=…). Hidden from tools/list in every mode, but stay callable.
346 let p = ToolProfile::Power;
347 assert!(!is_tool_visible(
348 "ctx_semantic_search",
349 &p,
350 &[],
351 no_quirks(),
352 true
353 ));
354 assert!(!is_tool_visible("ctx_symbol", &p, &[], no_quirks(), true));
355 assert!(is_tool_visible("ctx_search", &p, &[], no_quirks(), true));
356 }
357
358 #[test]
359 fn minimal_hides_non_minimal_tools() {
360 let p = ToolProfile::Minimal;
361 assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
362 assert!(!is_tool_visible(
363 "ctx_architecture",
364 &p,
365 &[],
366 no_quirks(),
367 true
368 ));
369 }
370
371 #[test]
372 fn disabled_list_filters() {
373 let disabled = vec!["ctx_read".to_string()];
374 assert!(!is_tool_visible(
375 "ctx_read",
376 &ToolProfile::Power,
377 &disabled,
378 no_quirks(),
379 true
380 ));
381 }
382
383 #[test]
384 fn zed_hides_ctx_edit_only() {
385 let p = ToolProfile::Power;
386 let zed = ClientQuirks {
387 hide_ctx_edit: true,
388 hide_ctx_patch: false,
389 };
390 assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
391 assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
392 }
393
394 #[test]
395 fn native_editor_quirk_hides_ctx_patch_only() {
396 // #1008: a native-editor client in the lazy default drops ctx_patch —
397 // and nothing else.
398 let p = ToolProfile::Power;
399 let native = ClientQuirks {
400 hide_ctx_edit: false,
401 hide_ctx_patch: true,
402 };
403 assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
404 assert!(is_tool_visible("ctx_read", &p, &[], native, true));
405 assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
406 }
407
408 #[test]
409 fn quirks_resolution_is_client_and_candidate_aware() {
410 // Native-editor clients skip ctx_patch in the lazy default…
411 for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
412 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
413 assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
414 }
415 // …clients without a reliable native editor get it (#637: Claude Code's
416 // read-before-write guard breaks under the read-redirect hook).
417 for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
418 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
419 assert!(
420 !q.hide_ctx_patch,
421 "{client:?}: lazy core must show ctx_patch"
422 );
423 }
424 // A pinned profile is client-agnostic — never hide ctx_patch there.
425 for candidate in [
426 CandidateSet::ProfileAuthoritative,
427 CandidateSet::Full,
428 CandidateSet::Unified,
429 ] {
430 let q = ClientQuirks::resolve("Cursor", candidate);
431 assert!(
432 !q.hide_ctx_patch,
433 "{candidate:?}: pinned/full surfaces are client-agnostic"
434 );
435 }
436 // The Zed ctx_edit quirk is independent of the candidate set.
437 assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
438 assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
439 }
440
441 #[test]
442 fn role_block_hides_tool() {
443 assert!(!is_tool_visible(
444 "ctx_read",
445 &ToolProfile::Power,
446 &[],
447 no_quirks(),
448 false
449 ));
450 }
451
452 #[test]
453 fn category_gate_only_in_default_lean_mode() {
454 // Lazy gate applies only when the client supports list_changed AND no
455 // explicit profile is set.
456 assert!(category_gate_applies(true, false));
457 // Explicit profile is authoritative — never gated (#358).
458 assert!(!category_gate_applies(true, true));
459 // Static-list clients are never gated regardless of profile.
460 assert!(!category_gate_applies(false, false));
461 assert!(!category_gate_applies(false, true));
462 }
463
464 #[test]
465 fn invoker_added_when_missing_in_lazy_mode() {
466 assert!(needs_invoker(false, false, true, &[]));
467 }
468
469 #[test]
470 fn invoker_not_added_in_full_mode() {
471 assert!(!needs_invoker(true, false, true, &[]));
472 }
473
474 #[test]
475 fn invoker_not_duplicated_when_present() {
476 assert!(!needs_invoker(false, true, true, &[]));
477 }
478
479 #[test]
480 fn invoker_respects_role_and_disabled() {
481 assert!(!needs_invoker(false, false, false, &[]));
482 assert!(!needs_invoker(
483 false,
484 false,
485 true,
486 &["ctx_call".to_string()]
487 ));
488 }
489
490 /// #576 schema diet: the lazy-core surface is the default fixed cost every
491 /// session pays — keep it bounded. Per-tool cap keeps any single schema
492 /// from bloating; the total cap keeps the whole advertised surface lean.
493 /// (Raw registry defs, before description compression — worst case.)
494 ///
495 /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
496 /// it joined the lean core so agents discover semantic search by default
497 /// instead of never reaching for it. The per-tool cap (300) still guards
498 /// individual bloat; the total budget is sized to that 14-tool surface.
499 ///
500 /// Bumped to 2260 for #432: `ctx_read` now advertises the `offset`/`limit`
501 /// aliases (so agents trained on the native Read tool discover them), a
502 /// deliberate +~32 tok. Descriptions are kept terse to limit the cost.
503 ///
504 /// Bumped to 2275 for #451: `ctx_shell` now states it runs the system shell
505 /// profile-free (no rc/profile sourced), a deliberate +~13 tok so agents stop
506 /// mistaking it for a config-loaded interactive bash. Kept to one terse clause.
507 ///
508 /// Bumped to per-tool 335 / total 2310 for #513: `ctx_read` now documents the
509 /// verbatim escape hatch (`raw=true` arg + `raw` mode) so agents — especially
510 /// non-Opus models that fought the compression — discover how to get exact
511 /// bytes for review/audit instead of guessing. `ctx_read` is the richest core
512 /// tool and is the only one that crosses 300; the per-tool cap still guards
513 /// every other tool from bloat. Kept to terse clauses (+~33 tok on ctx_read).
514 ///
515 /// Bumped to per-tool 360 / total 2340 for #509: `ctx_read` absorbs the
516 /// `ctx_multi_read` batch capability via a `paths` array, so two tools collapse
517 /// into one (`ctx_smart_read` + `ctx_multi_read` are now deprecated aliases
518 /// hidden from the surface). The net effect REDUCES the advertised surface; the
519 /// only local cost is +~18 tok on `ctx_read`'s schema for the new `paths` arg.
520 ///
521 /// #509 search consolidation (cont.): `ctx_search` now subsumes semantic
522 /// search + symbol lookup via an `action` enum, so `ctx_semantic_search` left
523 /// the core set (it + `ctx_symbol` are deprecated aliases). `ctx_search` grew
524 /// (~196 → ~318 tok) but the core total DROPPED (~2298 → ~2150, one fewer
525 /// tool), so the budgets were left unchanged with comfortable headroom.
526 ///
527 /// #578 schema diet: redundant per-property descriptions dropped (names +
528 /// enums self-explain), teaching paragraphs tightened, and `ctx_callgraph`
529 /// (~147 tok) replaced `ctx_graph` (~300 tok) in the lazy core so the
530 /// advertised set matches the injected INTENT playbook. Measured ~1685 tok
531 /// → budgets lowered 360→300 per tool, 2340→1780 total. What remains is
532 /// functional teaching (ctx_read mode enum, ctx_search action routing,
533 /// compose-first) — cut below this only with A/B efficacy evidence.
534 ///
535 /// Bumped to 2050 total for #1008: `ctx_patch` (anchored editing, ~263 tok
536 /// after its schema diet) joined the lazy core so the injected "edit after
537 /// reading → ctx_patch" rule points at an advertised tool. This is the
538 /// worst case (no client quirks): clients with a reliable native editor
539 /// (Cursor, Zed, Windsurf, …) skip `ctx_patch` via `ClientQuirks` and stay
540 /// at the previous ~1685-tok surface.
541 #[test]
542 fn core_tool_surface_stays_within_budget() {
543 const PER_TOOL_BUDGET: usize = 300;
544 const TOTAL_BUDGET: usize = 2050;
545
546 let _guard = crate::core::data_dir::isolated_data_dir();
547 let core = crate::tool_defs::core_tool_names();
548 let defs: Vec<_> = crate::server::registry::build_registry()
549 .tool_defs()
550 .into_iter()
551 .filter(|t| core.contains(&t.name.as_ref()))
552 .collect();
553 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
554
555 let mut total = 0usize;
556 for t in &defs {
557 let desc = t.description.as_deref().unwrap_or("");
558 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
559 let cost = crate::core::tokens::count_tokens(desc)
560 + crate::core::tokens::count_tokens(&schema);
561 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
562 assert!(
563 cost <= PER_TOOL_BUDGET,
564 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
565 t.name
566 );
567 total += cost;
568 }
569 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
570 assert!(
571 total <= TOTAL_BUDGET,
572 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
573 );
574 }
575}