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::{categorize_tool, ToolCategory};
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        if 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
135    let level = crate::core::config::CompressionLevel::effective(&cfg);
136    let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
137    if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
138        return tools;
139    }
140    tools
141        .into_iter()
142        .map(|mut t| {
143            let compressed = crate::core::terse::mcp_compress::compress_description(
144                t.name.as_ref(),
145                t.description.as_deref().unwrap_or(""),
146                mode,
147            );
148            t.description = Some(compressed.into());
149            t
150        })
151        .collect()
152}
153
154/// Whether the lazy per-category gate should filter the advertised tool set.
155///
156/// The dynamic-tools category gate (load tools on demand, signalled via
157/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
158/// surface small for capable clients. An explicit profile is the user's chosen,
159/// authoritative surface, so it must be advertised in full — otherwise category
160/// gating silently drops profile-enabled tools (e.g. Standard's
161/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
162/// advertised set stops matching `lean-ctx tools show` (#358).
163#[must_use]
164pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
165    supports_list_changed && !explicit_profile
166}
167
168/// Whether [`INVOKER`] must be force-added to the advertised set.
169///
170/// True only in non-full mode when it isn't already present, the role permits
171/// it, and it isn't explicitly disabled. In full mode every tool is already
172/// listed, so no gateway is needed.
173#[must_use]
174pub fn needs_invoker(
175    full_mode: bool,
176    already_present: bool,
177    invoker_role_allowed: bool,
178    disabled: &[String],
179) -> bool {
180    !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn internal_tools_never_visible_even_in_power() {
189        // Power enables everything, but Internal/meta tools must still be hidden.
190        let p = ToolProfile::Power;
191        assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
192        assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
193        assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
194    }
195
196    #[test]
197    fn core_tool_visible_under_power() {
198        assert!(is_tool_visible(
199            "ctx_read",
200            &ToolProfile::Power,
201            &[],
202            false,
203            true
204        ));
205    }
206
207    #[test]
208    fn standard_exposes_its_advertised_tools() {
209        // These are in STANDARD_TOOLS but were dropped by the old
210        // `core ∩ standard` intersection. Profile-authoritative resolution must
211        // surface them.
212        let p = ToolProfile::Standard;
213        assert!(is_tool_visible("ctx_architecture", &p, &[], false, true));
214        assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
215        assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
216    }
217
218    #[test]
219    fn minimal_hides_non_minimal_tools() {
220        let p = ToolProfile::Minimal;
221        assert!(is_tool_visible("ctx_read", &p, &[], false, true));
222        assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
223    }
224
225    #[test]
226    fn disabled_list_filters() {
227        let disabled = vec!["ctx_read".to_string()];
228        assert!(!is_tool_visible(
229            "ctx_read",
230            &ToolProfile::Power,
231            &disabled,
232            false,
233            true
234        ));
235    }
236
237    #[test]
238    fn zed_hides_ctx_edit_only() {
239        let p = ToolProfile::Power;
240        assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
241        assert!(is_tool_visible("ctx_read", &p, &[], true, true));
242    }
243
244    #[test]
245    fn role_block_hides_tool() {
246        assert!(!is_tool_visible(
247            "ctx_read",
248            &ToolProfile::Power,
249            &[],
250            false,
251            false
252        ));
253    }
254
255    #[test]
256    fn category_gate_only_in_default_lean_mode() {
257        // Lazy gate applies only when the client supports list_changed AND no
258        // explicit profile is set.
259        assert!(category_gate_applies(true, false));
260        // Explicit profile is authoritative — never gated (#358).
261        assert!(!category_gate_applies(true, true));
262        // Static-list clients are never gated regardless of profile.
263        assert!(!category_gate_applies(false, false));
264        assert!(!category_gate_applies(false, true));
265    }
266
267    #[test]
268    fn invoker_added_when_missing_in_lazy_mode() {
269        assert!(needs_invoker(false, false, true, &[]));
270    }
271
272    #[test]
273    fn invoker_not_added_in_full_mode() {
274        assert!(!needs_invoker(true, false, true, &[]));
275    }
276
277    #[test]
278    fn invoker_not_duplicated_when_present() {
279        assert!(!needs_invoker(false, true, true, &[]));
280    }
281
282    #[test]
283    fn invoker_respects_role_and_disabled() {
284        assert!(!needs_invoker(false, false, false, &[]));
285        assert!(!needs_invoker(
286            false,
287            false,
288            true,
289            &["ctx_call".to_string()]
290        ));
291    }
292
293    /// #576 schema diet: the lazy-core surface is the default fixed cost every
294    /// session pays — keep it bounded. Per-tool cap keeps any single schema
295    /// from bloating; the total cap keeps the whole advertised surface lean.
296    /// (Raw registry defs, before description compression — worst case.)
297    ///
298    /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
299    /// it joined the lean core so agents discover semantic search by default
300    /// instead of never reaching for it. The per-tool cap (300) still guards
301    /// individual bloat; the total budget is sized to that 14-tool surface.
302    #[test]
303    fn core_tool_surface_stays_within_budget() {
304        const PER_TOOL_BUDGET: usize = 300;
305        const TOTAL_BUDGET: usize = 2220;
306
307        let _guard = crate::core::data_dir::isolated_data_dir();
308        let core = crate::tool_defs::core_tool_names();
309        let defs: Vec<_> = crate::server::registry::build_registry()
310            .tool_defs()
311            .into_iter()
312            .filter(|t| core.contains(&t.name.as_ref()))
313            .collect();
314        assert_eq!(defs.len(), core.len(), "every core tool must be registered");
315
316        let mut total = 0usize;
317        for t in &defs {
318            let desc = t.description.as_deref().unwrap_or("");
319            let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
320            let cost = crate::core::tokens::count_tokens(desc)
321                + crate::core::tokens::count_tokens(&schema);
322            eprintln!("{:24} {cost:4} tok", t.name.as_ref());
323            assert!(
324                cost <= PER_TOOL_BUDGET,
325                "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
326                t.name
327            );
328            total += cost;
329        }
330        eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
331        assert!(
332            total <= TOTAL_BUDGET,
333            "core surface costs {total} tok (budget {TOTAL_BUDGET})"
334        );
335    }
336}