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/// Decides whether a tool name should appear in `tools/list`.
21///
22/// `role_allows` is supplied by the caller (it depends on the active role, which
23/// is resolved outside this pure function). Internal tools are hidden
24/// unconditionally — they're invoked automatically or via [`INVOKER`].
25#[must_use]
26pub fn is_tool_visible(
27    name: &str,
28    profile: &ToolProfile,
29    disabled: &[String],
30    is_zed: bool,
31    role_allows: bool,
32) -> bool {
33    if categorize_tool(name) == ToolCategory::Internal {
34        return false;
35    }
36    if !profile.is_tool_enabled(name) {
37        return false;
38    }
39    if disabled.iter().any(|d| d == name) {
40        return false;
41    }
42    if is_zed && name == "ctx_edit" {
43        return false;
44    }
45    role_allows
46}
47
48/// Whether the lazy per-category gate should filter the advertised tool set.
49///
50/// The dynamic-tools category gate (load tools on demand, signalled via
51/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
52/// surface small for capable clients. An explicit profile is the user's chosen,
53/// authoritative surface, so it must be advertised in full — otherwise category
54/// gating silently drops profile-enabled tools (e.g. Standard's
55/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
56/// advertised set stops matching `lean-ctx tools show` (#358).
57#[must_use]
58pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
59    supports_list_changed && !explicit_profile
60}
61
62/// Whether [`INVOKER`] must be force-added to the advertised set.
63///
64/// True only in non-full mode when it isn't already present, the role permits
65/// it, and it isn't explicitly disabled. In full mode every tool is already
66/// listed, so no gateway is needed.
67#[must_use]
68pub fn needs_invoker(
69    full_mode: bool,
70    already_present: bool,
71    invoker_role_allowed: bool,
72    disabled: &[String],
73) -> bool {
74    !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn internal_tools_never_visible_even_in_power() {
83        // Power enables everything, but Internal/meta tools must still be hidden.
84        let p = ToolProfile::Power;
85        assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
86        assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
87        assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
88    }
89
90    #[test]
91    fn core_tool_visible_under_power() {
92        assert!(is_tool_visible(
93            "ctx_read",
94            &ToolProfile::Power,
95            &[],
96            false,
97            true
98        ));
99    }
100
101    #[test]
102    fn standard_exposes_its_advertised_tools() {
103        // These are in STANDARD_TOOLS but were dropped by the old
104        // `core ∩ standard` intersection. Profile-authoritative resolution must
105        // surface them.
106        let p = ToolProfile::Standard;
107        assert!(is_tool_visible("ctx_architecture", &p, &[], false, true));
108        assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
109        assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
110    }
111
112    #[test]
113    fn minimal_hides_non_minimal_tools() {
114        let p = ToolProfile::Minimal;
115        assert!(is_tool_visible("ctx_read", &p, &[], false, true));
116        assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
117    }
118
119    #[test]
120    fn disabled_list_filters() {
121        let disabled = vec!["ctx_read".to_string()];
122        assert!(!is_tool_visible(
123            "ctx_read",
124            &ToolProfile::Power,
125            &disabled,
126            false,
127            true
128        ));
129    }
130
131    #[test]
132    fn zed_hides_ctx_edit_only() {
133        let p = ToolProfile::Power;
134        assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
135        assert!(is_tool_visible("ctx_read", &p, &[], true, true));
136    }
137
138    #[test]
139    fn role_block_hides_tool() {
140        assert!(!is_tool_visible(
141            "ctx_read",
142            &ToolProfile::Power,
143            &[],
144            false,
145            false
146        ));
147    }
148
149    #[test]
150    fn category_gate_only_in_default_lean_mode() {
151        // Lazy gate applies only when the client supports list_changed AND no
152        // explicit profile is set.
153        assert!(category_gate_applies(true, false));
154        // Explicit profile is authoritative — never gated (#358).
155        assert!(!category_gate_applies(true, true));
156        // Static-list clients are never gated regardless of profile.
157        assert!(!category_gate_applies(false, false));
158        assert!(!category_gate_applies(false, true));
159    }
160
161    #[test]
162    fn invoker_added_when_missing_in_lazy_mode() {
163        assert!(needs_invoker(false, false, true, &[]));
164    }
165
166    #[test]
167    fn invoker_not_added_in_full_mode() {
168        assert!(!needs_invoker(true, false, true, &[]));
169    }
170
171    #[test]
172    fn invoker_not_duplicated_when_present() {
173        assert!(!needs_invoker(false, true, true, &[]));
174    }
175
176    #[test]
177    fn invoker_respects_role_and_disabled() {
178        assert!(!needs_invoker(false, false, false, &[]));
179        assert!(!needs_invoker(
180            false,
181            false,
182            true,
183            &["ctx_call".to_string()]
184        ));
185    }
186}