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 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    if !profile.is_tool_enabled(name) {
77        return false;
78    }
79    if disabled.iter().any(|d| d == name) {
80        return false;
81    }
82    if is_zed && name == "ctx_edit" {
83        return false;
84    }
85    role_allows
86}
87
88/// Computes the tool set this install advertises to a default client
89/// (no Zed quirk, no role restriction, no workflow gate, static tool list),
90/// including the live description compression. Offline counterpart of the
91/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
92/// kept next to the pure gates so measurement cannot drift from policy.
93#[must_use]
94pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
95    let cfg = crate::core::config::Config::load();
96    let disabled = cfg.disabled_tools_effective();
97    let profile = cfg.tool_profile_effective();
98    let full_mode = crate::tool_defs::is_full_mode();
99    let registry = crate::server::registry::build_registry();
100
101    let candidate = candidate_set(
102        full_mode,
103        std::env::var("LEAN_CTX_UNIFIED").is_ok(),
104        explicit_profile(&cfg),
105    );
106    let pool: Vec<rmcp::model::Tool> = match candidate {
107        CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
108        CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
109        CandidateSet::LazyCore => {
110            let core = crate::tool_defs::core_tool_names();
111            registry
112                .tool_defs()
113                .into_iter()
114                .filter(|t| core.contains(&t.name.as_ref()))
115                .collect()
116        }
117    };
118
119    let mut tools: Vec<_> = pool
120        .into_iter()
121        .filter(|t| is_tool_visible(t.name.as_ref(), &profile, &disabled, false, true))
122        .collect();
123
124    let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
125    if needs_invoker(full_mode, already, true, &disabled)
126        && let Some(def) = registry
127            .tool_defs()
128            .into_iter()
129            .find(|t| t.name.as_ref() == INVOKER)
130    {
131        tools.push(def);
132    }
133
134    let level = crate::core::config::CompressionLevel::effective(&cfg);
135    let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
136    if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
137        return tools;
138    }
139    tools
140        .into_iter()
141        .map(|mut t| {
142            let compressed = crate::core::terse::mcp_compress::compress_description(
143                t.name.as_ref(),
144                t.description.as_deref().unwrap_or(""),
145                mode,
146            );
147            t.description = Some(compressed.into());
148            t
149        })
150        .collect()
151}
152
153/// Whether the lazy per-category gate should filter the advertised tool set.
154///
155/// The dynamic-tools category gate (load tools on demand, signalled via
156/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
157/// surface small for capable clients. An explicit profile is the user's chosen,
158/// authoritative surface, so it must be advertised in full — otherwise category
159/// gating silently drops profile-enabled tools (e.g. Standard's
160/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
161/// advertised set stops matching `lean-ctx tools show` (#358).
162#[must_use]
163pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
164    supports_list_changed && !explicit_profile
165}
166
167/// Whether [`INVOKER`] must be force-added to the advertised set.
168///
169/// True only in non-full mode when it isn't already present, the role permits
170/// it, and it isn't explicitly disabled. In full mode every tool is already
171/// listed, so no gateway is needed.
172#[must_use]
173pub fn needs_invoker(
174    full_mode: bool,
175    already_present: bool,
176    invoker_role_allowed: bool,
177    disabled: &[String],
178) -> bool {
179    !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn internal_tools_never_visible_even_in_power() {
188        // Power enables everything, but Internal/meta tools must still be hidden.
189        let p = ToolProfile::Power;
190        assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
191        assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
192        assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
193    }
194
195    #[test]
196    fn core_tool_visible_under_power() {
197        assert!(is_tool_visible(
198            "ctx_read",
199            &ToolProfile::Power,
200            &[],
201            false,
202            true
203        ));
204    }
205
206    #[test]
207    fn standard_exposes_its_advertised_tools() {
208        // These are in STANDARD_TOOLS but were dropped by the old
209        // `core ∩ standard` intersection. Profile-authoritative resolution must
210        // surface them.
211        let p = ToolProfile::Standard;
212        assert!(is_tool_visible("ctx_architecture", &p, &[], false, true));
213        assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
214        assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
215    }
216
217    #[test]
218    fn minimal_hides_non_minimal_tools() {
219        let p = ToolProfile::Minimal;
220        assert!(is_tool_visible("ctx_read", &p, &[], false, true));
221        assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
222    }
223
224    #[test]
225    fn disabled_list_filters() {
226        let disabled = vec!["ctx_read".to_string()];
227        assert!(!is_tool_visible(
228            "ctx_read",
229            &ToolProfile::Power,
230            &disabled,
231            false,
232            true
233        ));
234    }
235
236    #[test]
237    fn zed_hides_ctx_edit_only() {
238        let p = ToolProfile::Power;
239        assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
240        assert!(is_tool_visible("ctx_read", &p, &[], true, true));
241    }
242
243    #[test]
244    fn role_block_hides_tool() {
245        assert!(!is_tool_visible(
246            "ctx_read",
247            &ToolProfile::Power,
248            &[],
249            false,
250            false
251        ));
252    }
253
254    #[test]
255    fn category_gate_only_in_default_lean_mode() {
256        // Lazy gate applies only when the client supports list_changed AND no
257        // explicit profile is set.
258        assert!(category_gate_applies(true, false));
259        // Explicit profile is authoritative — never gated (#358).
260        assert!(!category_gate_applies(true, true));
261        // Static-list clients are never gated regardless of profile.
262        assert!(!category_gate_applies(false, false));
263        assert!(!category_gate_applies(false, true));
264    }
265
266    #[test]
267    fn invoker_added_when_missing_in_lazy_mode() {
268        assert!(needs_invoker(false, false, true, &[]));
269    }
270
271    #[test]
272    fn invoker_not_added_in_full_mode() {
273        assert!(!needs_invoker(true, false, true, &[]));
274    }
275
276    #[test]
277    fn invoker_not_duplicated_when_present() {
278        assert!(!needs_invoker(false, true, true, &[]));
279    }
280
281    #[test]
282    fn invoker_respects_role_and_disabled() {
283        assert!(!needs_invoker(false, false, false, &[]));
284        assert!(!needs_invoker(
285            false,
286            false,
287            true,
288            &["ctx_call".to_string()]
289        ));
290    }
291
292    /// #576 schema diet: the lazy-core surface is the default fixed cost every
293    /// session pays — keep it bounded. Per-tool cap keeps any single schema
294    /// from bloating; the total cap keeps the whole advertised surface lean.
295    /// (Raw registry defs, before description compression — worst case.)
296    ///
297    /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
298    /// it joined the lean core so agents discover semantic search by default
299    /// instead of never reaching for it. The per-tool cap (300) still guards
300    /// individual bloat; the total budget is sized to that 14-tool surface.
301    ///
302    /// Bumped to 2260 for #432: `ctx_read` now advertises the `offset`/`limit`
303    /// aliases (so agents trained on the native Read tool discover them), a
304    /// deliberate +~32 tok. Descriptions are kept terse to limit the cost.
305    #[test]
306    fn core_tool_surface_stays_within_budget() {
307        const PER_TOOL_BUDGET: usize = 300;
308        const TOTAL_BUDGET: usize = 2260;
309
310        let _guard = crate::core::data_dir::isolated_data_dir();
311        let core = crate::tool_defs::core_tool_names();
312        let defs: Vec<_> = crate::server::registry::build_registry()
313            .tool_defs()
314            .into_iter()
315            .filter(|t| core.contains(&t.name.as_ref()))
316            .collect();
317        assert_eq!(defs.len(), core.len(), "every core tool must be registered");
318
319        let mut total = 0usize;
320        for t in &defs {
321            let desc = t.description.as_deref().unwrap_or("");
322            let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
323            let cost = crate::core::tokens::count_tokens(desc)
324                + crate::core::tokens::count_tokens(&schema);
325            eprintln!("{:24} {cost:4} tok", t.name.as_ref());
326            assert!(
327                cost <= PER_TOOL_BUDGET,
328                "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
329                t.name
330            );
331            total += cost;
332        }
333        eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
334        assert!(
335            total <= TOTAL_BUDGET,
336            "core surface costs {total} tok (budget {TOTAL_BUDGET})"
337        );
338    }
339}