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 Zed `ctx_edit` quirk filter
9//! the candidates.
10//! * The universal invoker (`ctx_call`) is force-advertised in non-full mode so
11//! tools hidden by lazy/profile filtering stay reachable.
12
13use super::dynamic_tools::{ToolCategory, categorize_tool};
14use crate::core::tool_profiles::ToolProfile;
15
16/// The universal invoker tool name. A static-list MCP client can call any
17/// registered tool through it, even when that tool isn't advertised.
18pub const INVOKER: &str = "ctx_call";
19
20/// Which candidate pool `tools/list` starts from, before per-tool gates run.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CandidateSet {
23 /// Full registry (`LEAN_CTX_FULL_TOOLS=1` / `LEAN_CTX_LAZY_TOOLS=0`).
24 Full,
25 /// Consolidated unified surface (`LEAN_CTX_UNIFIED`).
26 Unified,
27 /// The user pinned a profile — it is authoritative and resolves against
28 /// the full registry (#358), so `standard` advertises its complete set.
29 ProfileAuthoritative,
30 /// Lean default: only `CORE_TOOL_NAMES` are advertised; everything else
31 /// stays reachable through [`INVOKER`] (#575).
32 LazyCore,
33}
34
35/// Decides the candidate pool. Single source of truth for the `tools/list`
36/// handler AND offline measurement (`doctor overhead`), so the advertised
37/// surface and the reported overhead can never drift apart.
38#[must_use]
39pub fn candidate_set(full_mode: bool, unified_env: bool, explicit_profile: bool) -> CandidateSet {
40 if full_mode {
41 CandidateSet::Full
42 } else if unified_env {
43 CandidateSet::Unified
44 } else if explicit_profile {
45 CandidateSet::ProfileAuthoritative
46 } else {
47 CandidateSet::LazyCore
48 }
49}
50
51/// Whether the user explicitly pinned a tool profile (config key, custom tool
52/// list, or env var) — the trigger for [`CandidateSet::ProfileAuthoritative`].
53#[must_use]
54pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
55 cfg.tool_profile.is_some()
56 || !cfg.tools_enabled.is_empty()
57 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
58}
59
60/// Decides whether a tool name should appear in `tools/list`.
61///
62/// `role_allows` is supplied by the caller (it depends on the active role, which
63/// is resolved outside this pure function). Internal tools are hidden
64/// unconditionally — they're invoked automatically or via [`INVOKER`].
65#[must_use]
66pub fn is_tool_visible(
67 name: &str,
68 profile: &ToolProfile,
69 disabled: &[String],
70 is_zed: bool,
71 role_allows: bool,
72) -> bool {
73 if categorize_tool(name) == ToolCategory::Internal {
74 return false;
75 }
76 // #509: deprecated read-cluster aliases (ctx_smart_read, ctx_multi_read) are
77 // hidden from the advertised surface but stay callable for one release.
78 if super::dynamic_tools::is_deprecated_alias(name) {
79 return false;
80 }
81 if !profile.is_tool_enabled(name) {
82 return false;
83 }
84 if disabled.iter().any(|d| d == name) {
85 return false;
86 }
87 if is_zed && name == "ctx_edit" {
88 return false;
89 }
90 role_allows
91}
92
93/// Computes the tool set this install advertises to a default client
94/// (no Zed quirk, no role restriction, no workflow gate, static tool list),
95/// including the live description compression. Offline counterpart of the
96/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
97/// kept next to the pure gates so measurement cannot drift from policy.
98#[must_use]
99pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
100 let cfg = crate::core::config::Config::load();
101 let disabled = cfg.disabled_tools_effective();
102 let profile = cfg.tool_profile_effective();
103 let full_mode = crate::tool_defs::is_full_mode();
104 let registry = crate::server::registry::build_registry();
105
106 let candidate = candidate_set(
107 full_mode,
108 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
109 explicit_profile(&cfg),
110 );
111 let pool: Vec<rmcp::model::Tool> = match candidate {
112 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
113 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
114 CandidateSet::LazyCore => {
115 let core = crate::tool_defs::core_tool_names();
116 registry
117 .tool_defs()
118 .into_iter()
119 .filter(|t| core.contains(&t.name.as_ref()))
120 .collect()
121 }
122 };
123
124 let mut tools: Vec<_> = pool
125 .into_iter()
126 .filter(|t| is_tool_visible(t.name.as_ref(), &profile, &disabled, false, true))
127 .collect();
128
129 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
130 if needs_invoker(full_mode, already, true, &disabled)
131 && let Some(def) = registry
132 .tool_defs()
133 .into_iter()
134 .find(|t| t.name.as_ref() == INVOKER)
135 {
136 tools.push(def);
137 }
138
139 let level = crate::core::config::CompressionLevel::effective(&cfg);
140 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
141 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
142 return tools;
143 }
144 tools
145 .into_iter()
146 .map(|mut t| {
147 let compressed = crate::core::terse::mcp_compress::compress_description(
148 t.name.as_ref(),
149 t.description.as_deref().unwrap_or(""),
150 mode,
151 );
152 t.description = Some(compressed.into());
153 t
154 })
155 .collect()
156}
157
158/// Whether the lazy per-category gate should filter the advertised tool set.
159///
160/// The dynamic-tools category gate (load tools on demand, signalled via
161/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
162/// surface small for capable clients. An explicit profile is the user's chosen,
163/// authoritative surface, so it must be advertised in full — otherwise category
164/// gating silently drops profile-enabled tools (e.g. Standard's
165/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
166/// advertised set stops matching `lean-ctx tools show` (#358).
167#[must_use]
168pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
169 supports_list_changed && !explicit_profile
170}
171
172/// Whether [`INVOKER`] must be force-added to the advertised set.
173///
174/// True only in non-full mode when it isn't already present, the role permits
175/// it, and it isn't explicitly disabled. In full mode every tool is already
176/// listed, so no gateway is needed.
177#[must_use]
178pub fn needs_invoker(
179 full_mode: bool,
180 already_present: bool,
181 invoker_role_allowed: bool,
182 disabled: &[String],
183) -> bool {
184 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn internal_tools_never_visible_even_in_power() {
193 // Power enables everything, but Internal/meta tools must still be hidden.
194 let p = ToolProfile::Power;
195 assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
196 assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
197 assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
198 }
199
200 #[test]
201 fn deprecated_aliases_never_visible_even_in_power() {
202 // #509: folded read-cluster aliases are hidden from tools/list in every
203 // mode (Power enables everything) — but stay registered + callable.
204 let p = ToolProfile::Power;
205 assert!(!is_tool_visible("ctx_smart_read", &p, &[], false, true));
206 assert!(!is_tool_visible("ctx_multi_read", &p, &[], false, true));
207 }
208
209 #[test]
210 fn deprecated_aliases_stay_registered_and_callable() {
211 // The non-breaking contract (#509): hidden from the advertised surface,
212 // but still in the registry so direct calls and ctx_call keep working
213 // for one release. Removal is Phase 2.
214 let _guard = crate::core::data_dir::isolated_data_dir();
215 let defs = crate::server::registry::build_registry().tool_defs();
216 for name in [
217 "ctx_smart_read",
218 "ctx_multi_read",
219 "ctx_semantic_search",
220 "ctx_symbol",
221 ] {
222 assert!(
223 defs.iter().any(|t| t.name.as_ref() == name),
224 "{name} must stay registered (callable) even though hidden"
225 );
226 assert!(
227 !is_tool_visible(name, &ToolProfile::Power, &[], false, true),
228 "{name} must be hidden from tools/list"
229 );
230 }
231 }
232
233 #[test]
234 fn core_tool_visible_under_power() {
235 assert!(is_tool_visible(
236 "ctx_read",
237 &ToolProfile::Power,
238 &[],
239 false,
240 true
241 ));
242 }
243
244 #[test]
245 fn standard_exposes_its_advertised_tools() {
246 // These are in STANDARD_TOOLS but were dropped by the old
247 // `core ∩ standard` intersection. Profile-authoritative resolution must
248 // surface them.
249 let p = ToolProfile::Standard;
250 assert!(is_tool_visible("ctx_execute", &p, &[], false, true));
251 assert!(is_tool_visible("ctx_explore", &p, &[], false, true));
252 assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
253 assert!(is_tool_visible("ctx_graph", &p, &[], false, true));
254 }
255
256 #[test]
257 fn folded_search_aliases_never_visible() {
258 // #509: ctx_semantic_search + ctx_symbol are consolidated into ctx_search
259 // (action=…). Hidden from tools/list in every mode, but stay callable.
260 let p = ToolProfile::Power;
261 assert!(!is_tool_visible(
262 "ctx_semantic_search",
263 &p,
264 &[],
265 false,
266 true
267 ));
268 assert!(!is_tool_visible("ctx_symbol", &p, &[], false, true));
269 assert!(is_tool_visible("ctx_search", &p, &[], false, true));
270 }
271
272 #[test]
273 fn minimal_hides_non_minimal_tools() {
274 let p = ToolProfile::Minimal;
275 assert!(is_tool_visible("ctx_read", &p, &[], false, true));
276 assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
277 }
278
279 #[test]
280 fn disabled_list_filters() {
281 let disabled = vec!["ctx_read".to_string()];
282 assert!(!is_tool_visible(
283 "ctx_read",
284 &ToolProfile::Power,
285 &disabled,
286 false,
287 true
288 ));
289 }
290
291 #[test]
292 fn zed_hides_ctx_edit_only() {
293 let p = ToolProfile::Power;
294 assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
295 assert!(is_tool_visible("ctx_read", &p, &[], true, true));
296 }
297
298 #[test]
299 fn role_block_hides_tool() {
300 assert!(!is_tool_visible(
301 "ctx_read",
302 &ToolProfile::Power,
303 &[],
304 false,
305 false
306 ));
307 }
308
309 #[test]
310 fn category_gate_only_in_default_lean_mode() {
311 // Lazy gate applies only when the client supports list_changed AND no
312 // explicit profile is set.
313 assert!(category_gate_applies(true, false));
314 // Explicit profile is authoritative — never gated (#358).
315 assert!(!category_gate_applies(true, true));
316 // Static-list clients are never gated regardless of profile.
317 assert!(!category_gate_applies(false, false));
318 assert!(!category_gate_applies(false, true));
319 }
320
321 #[test]
322 fn invoker_added_when_missing_in_lazy_mode() {
323 assert!(needs_invoker(false, false, true, &[]));
324 }
325
326 #[test]
327 fn invoker_not_added_in_full_mode() {
328 assert!(!needs_invoker(true, false, true, &[]));
329 }
330
331 #[test]
332 fn invoker_not_duplicated_when_present() {
333 assert!(!needs_invoker(false, true, true, &[]));
334 }
335
336 #[test]
337 fn invoker_respects_role_and_disabled() {
338 assert!(!needs_invoker(false, false, false, &[]));
339 assert!(!needs_invoker(
340 false,
341 false,
342 true,
343 &["ctx_call".to_string()]
344 ));
345 }
346
347 /// #576 schema diet: the lazy-core surface is the default fixed cost every
348 /// session pays — keep it bounded. Per-tool cap keeps any single schema
349 /// from bloating; the total cap keeps the whole advertised surface lean.
350 /// (Raw registry defs, before description compression — worst case.)
351 ///
352 /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
353 /// it joined the lean core so agents discover semantic search by default
354 /// instead of never reaching for it. The per-tool cap (300) still guards
355 /// individual bloat; the total budget is sized to that 14-tool surface.
356 ///
357 /// Bumped to 2260 for #432: `ctx_read` now advertises the `offset`/`limit`
358 /// aliases (so agents trained on the native Read tool discover them), a
359 /// deliberate +~32 tok. Descriptions are kept terse to limit the cost.
360 ///
361 /// Bumped to 2275 for #451: `ctx_shell` now states it runs the system shell
362 /// profile-free (no rc/profile sourced), a deliberate +~13 tok so agents stop
363 /// mistaking it for a config-loaded interactive bash. Kept to one terse clause.
364 ///
365 /// Bumped to per-tool 335 / total 2310 for #513: `ctx_read` now documents the
366 /// verbatim escape hatch (`raw=true` arg + `raw` mode) so agents — especially
367 /// non-Opus models that fought the compression — discover how to get exact
368 /// bytes for review/audit instead of guessing. `ctx_read` is the richest core
369 /// tool and is the only one that crosses 300; the per-tool cap still guards
370 /// every other tool from bloat. Kept to terse clauses (+~33 tok on ctx_read).
371 ///
372 /// Bumped to per-tool 360 / total 2340 for #509: `ctx_read` absorbs the
373 /// `ctx_multi_read` batch capability via a `paths` array, so two tools collapse
374 /// into one (`ctx_smart_read` + `ctx_multi_read` are now deprecated aliases
375 /// hidden from the surface). The net effect REDUCES the advertised surface; the
376 /// only local cost is +~18 tok on `ctx_read`'s schema for the new `paths` arg.
377 ///
378 /// #509 search consolidation (cont.): `ctx_search` now subsumes semantic
379 /// search + symbol lookup via an `action` enum, so `ctx_semantic_search` left
380 /// the core set (it + `ctx_symbol` are deprecated aliases). `ctx_search` grew
381 /// (~196 → ~318 tok) but the core total DROPPED (~2298 → ~2150, one fewer
382 /// tool), so the budgets are left unchanged with comfortable headroom.
383 #[test]
384 fn core_tool_surface_stays_within_budget() {
385 const PER_TOOL_BUDGET: usize = 360;
386 const TOTAL_BUDGET: usize = 2340;
387
388 let _guard = crate::core::data_dir::isolated_data_dir();
389 let core = crate::tool_defs::core_tool_names();
390 let defs: Vec<_> = crate::server::registry::build_registry()
391 .tool_defs()
392 .into_iter()
393 .filter(|t| core.contains(&t.name.as_ref()))
394 .collect();
395 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
396
397 let mut total = 0usize;
398 for t in &defs {
399 let desc = t.description.as_deref().unwrap_or("");
400 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
401 let cost = crate::core::tokens::count_tokens(desc)
402 + crate::core::tokens::count_tokens(&schema);
403 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
404 assert!(
405 cost <= PER_TOOL_BUDGET,
406 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
407 t.name
408 );
409 total += cost;
410 }
411 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
412 assert!(
413 total <= TOTAL_BUDGET,
414 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
415 );
416 }
417}