Skip to main content

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}