Skip to main content

lean_ctx_server/server/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use rmcp::model::Tool;
6
7use super::tool_trait::McpTool;
8
9/// Central registry mapping tool names to their trait-based handlers.
10/// Every tool is trait-based and resolved here; the earlier
11/// match-cascade dispatch has been fully retired.
12///
13/// Handlers are stored behind `Arc` (not `Box`) so the dispatch layer can hand
14/// an owned, `'static` handle to `tokio::task::spawn_blocking`. That lets a
15/// blocking handler run on the dedicated blocking pool under a watchdog
16/// deadline instead of pinning a scarce core worker via `block_in_place`
17/// (#271 — a hung handler must never swallow the JSON-RPC response).
18pub struct ToolRegistry {
19    tools: HashMap<&'static str, Arc<dyn McpTool>>,
20}
21
22impl ToolRegistry {
23    pub fn new() -> Self {
24        Self {
25            tools: HashMap::new(),
26        }
27    }
28
29    pub fn register(&mut self, tool: Box<dyn McpTool>) {
30        let name = tool.name();
31        self.tools.insert(name, Arc::from(tool));
32    }
33
34    pub fn get(&self, name: &str) -> Option<&dyn McpTool> {
35        self.tools.get(name).map(|t| &**t)
36    }
37
38    /// Clone an owned, `'static` handle to a registered tool.
39    ///
40    /// Unlike [`get`](Self::get), the returned `Arc` can be moved into
41    /// `spawn_blocking`, so the dispatch layer can execute the (synchronous)
42    /// handler off the async core workers under a watchdog (#271).
43    pub fn get_arc(&self, name: &str) -> Option<Arc<dyn McpTool>> {
44        self.tools.get(name).cloned()
45    }
46
47    pub fn contains(&self, name: &str) -> bool {
48        self.tools.contains_key(name)
49    }
50
51    /// The registry's own `'static` key for `name`, if it is a registered tool.
52    /// Telemetry counts per tool only under these keys, so a caller-supplied
53    /// string never reaches a counter.
54    pub fn static_name(&self, name: &str) -> Option<&'static str> {
55        self.tools.get_key_value(name).map(|(key, _)| *key)
56    }
57
58    /// Returns MCP Tool definitions in the official public surface.
59    ///
60    /// Deprecated aliases and local-only collaboration compatibility tools
61    /// remain callable through dispatch but are neither published nor counted
62    /// as official tools.
63    /// Applies MCP `ToolAnnotations` (readOnlyHint, destructiveHint) so clients
64    /// can make informed decisions about tool usage in restricted contexts.
65    pub fn tool_defs(&self) -> Vec<Tool> {
66        self.tool_defs_filtered(false)
67    }
68
69    /// Returns MCP Tool definitions for every registered tool, including
70    /// deprecated aliases and local-only compatibility substrate.
71    /// Used by callability and schema checks that cover the full registry.
72    pub fn registered_tool_defs(&self) -> Vec<Tool> {
73        self.tool_defs_filtered(true)
74    }
75
76    /// `include_non_public` is reserved for direct compatibility checks; all
77    /// discovery paths use the public surface by default.
78    fn tool_defs_filtered(&self, include_non_public: bool) -> Vec<Tool> {
79        let mut defs: Vec<Tool> = self
80            .tools
81            .values()
82            .filter(|t| {
83                include_non_public || super::dynamic_tools::is_publicly_advertised_tool(t.name())
84            })
85            .map(|t| t.tool_def())
86            .collect();
87        defs.sort_by(|a, b| a.name.as_ref().cmp(b.name.as_ref()));
88        lean_ctx::tool_defs::apply_tool_annotations(defs)
89    }
90
91    /// Returns tool definitions filtered by the dynamic tool state.
92    /// Only includes tools whose category is currently active.
93    pub fn active_tool_defs(&self) -> Vec<Tool> {
94        let Ok(state) = super::dynamic_tools::global().lock() else {
95            tracing::warn!("dynamic_tools mutex poisoned in active_tool_defs; returning all");
96            return self.tool_defs();
97        };
98        let mut defs: Vec<Tool> = self
99            .tools
100            .values()
101            .filter(|t| {
102                state.is_tool_active(t.name())
103                    && super::dynamic_tools::is_publicly_advertised_tool(t.name())
104            })
105            .map(|t| t.tool_def())
106            .collect();
107        defs.sort_by(|a, b| a.name.as_ref().cmp(b.name.as_ref()));
108        lean_ctx::tool_defs::apply_tool_annotations(defs)
109    }
110
111    /// Returns tool definitions filtered by a tool profile.
112    /// Only includes tools whose name is enabled by the given profile.
113    pub fn profile_tool_defs(
114        &self,
115        profile: &lean_ctx::core::tool_profiles::ToolProfile,
116    ) -> Vec<Tool> {
117        let mut defs: Vec<Tool> = self
118            .tools
119            .values()
120            .filter(|t| {
121                profile.is_tool_enabled(t.name())
122                    && super::dynamic_tools::is_publicly_advertised_tool(t.name())
123            })
124            .map(|t| t.tool_def())
125            .collect();
126        defs.sort_by(|a, b| a.name.as_ref().cmp(b.name.as_ref()));
127        lean_ctx::tool_defs::apply_tool_annotations(defs)
128    }
129
130    pub fn len(&self) -> usize {
131        self.tools.len()
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.tools.is_empty()
136    }
137
138    pub fn names(&self) -> Vec<&'static str> {
139        let mut names: Vec<_> = self.tools.keys().copied().collect();
140        names.sort_unstable();
141        names
142    }
143}
144
145impl Default for ToolRegistry {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// Number of official MCP tools — the single source of truth for the
152/// "N MCP tools" count shown in `--help`, the README, and the feature catalog.
153/// Deprecated aliases remain callable for compatibility but are not counted.
154pub fn tool_count() -> usize {
155    build_registry().tool_defs().len()
156}
157
158/// Hand this registry's tool definitions to the engine's
159/// [`lean_ctx::core::tool_catalog`], which manifests, overhead measurement and
160/// discovery cards read. Idempotent; the CLI calls it at start-up and every
161/// MCP server construction calls it too.
162pub fn register_tool_catalog() {
163    lean_ctx::core::tool_catalog::register_tool_catalog(
164        lean_ctx::core::tool_catalog::ToolCatalog {
165            tool_defs: || build_registry().tool_defs(),
166            registered_tool_defs: || build_registry().registered_tool_defs(),
167        },
168    );
169}
170
171/// Register all trait-based tools. Called once during server startup.
172/// New tools are added here as their `McpTool` implementation lands.
173pub fn build_registry() -> ToolRegistry {
174    let mut registry = ToolRegistry::new();
175
176    use crate::tools::registered;
177    registry.register(Box::new(registered::ctx_tree::CtxTreeTool));
178    registry.register(Box::new(registered::ctx_discover::CtxDiscoverTool));
179    registry.register(Box::new(registered::ctx_heatmap::CtxHeatmapTool));
180    registry.register(Box::new(registered::ctx_verify::CtxVerifyTool));
181    registry.register(Box::new(registered::ctx_outline::CtxOutlineTool));
182    registry.register(Box::new(registered::ctx_cost::CtxCostTool));
183    registry.register(Box::new(registered::ctx_gain::CtxGainTool));
184    registry.register(Box::new(registered::ctx_expand::CtxExpandTool));
185    registry.register(Box::new(registered::ctx_call::CtxCallTool));
186    registry.register(Box::new(registered::ctx_callgraph::CtxCallgraphTool));
187    registry.register(Box::new(
188        registered::ctx_discover_tools::CtxDiscoverToolsTool,
189    ));
190    registry.register(Box::new(registered::ctx_tools::CtxToolsTool));
191    registry.register(Box::new(registered::ctx_provider::CtxProviderTool));
192    registry.register(Box::new(registered::ctx_doc::CtxDocTool));
193    registry.register(Box::new(registered::ctx_pack::CtxPackTool));
194    registry.register(Box::new(registered::ctx_index::CtxIndexTool));
195    registry.register(Box::new(registered::ctx_artifacts::CtxArtifactsTool));
196    registry.register(Box::new(
197        registered::ctx_compress_memory::CtxCompressMemoryTool,
198    ));
199    registry.register(Box::new(registered::ctx_read::CtxReadTool));
200    registry.register(Box::new(registered::ctx_multi_repo::CtxMultiRepoTool));
201    registry.register(Box::new(registered::ctx_delta::CtxDeltaTool));
202    registry.register(Box::new(registered::ctx_edit::CtxEditTool));
203    registry.register(Box::new(registered::ctx_patch::CtxPatchTool));
204    registry.register(Box::new(registered::ctx_glob::CtxGlobTool));
205    registry.register(Box::new(registered::ctx_shell::CtxShellTool));
206    registry.register(Box::new(registered::ctx_shape::CtxShapeTool));
207    registry.register(Box::new(registered::shell_alias::ShellAliasTool));
208    registry.register(Box::new(registered::ctx_search::CtxSearchTool));
209    registry.register(Box::new(registered::ctx_url_read::CtxUrlReadTool));
210    registry.register(Box::new(registered::ctx_git_read::CtxGitReadTool));
211    registry.register(Box::new(registered::ctx_checkpoint::CtxCheckpointTool));
212    registry.register(Box::new(registered::ctx_compose::CtxComposeTool));
213    registry.register(Box::new(registered::ctx_memory::CtxMemoryTool));
214    registry.register(Box::new(registered::ctx_execute::CtxExecuteTool));
215
216    // Utility tools (migrated from dispatch/utility_tools.rs)
217    registry.register(Box::new(registered::ctx_compress::CtxCompressTool));
218    registry.register(Box::new(registered::ctx_metrics::CtxMetricsTool));
219    registry.register(Box::new(registered::ctx_radar::CtxRadarTool));
220    registry.register(Box::new(registered::ctx_dedup::CtxDedupTool));
221    registry.register(Box::new(registered::ctx_context::CtxContextTool));
222    registry.register(Box::new(registered::ctx_graph::CtxGraphTool));
223    registry.register(Box::new(registered::ctx_proof::CtxProofTool));
224    registry.register(Box::new(registered::ctx_preload::CtxPreloadTool));
225    registry.register(Box::new(registered::ctx_prefetch::CtxPrefetchTool));
226    registry.register(Box::new(registered::ctx_feedback::CtxFeedbackTool));
227    registry.register(Box::new(registered::ctx_control::CtxControlTool));
228
229    // Session tools (migrated from legacy dispatch)
230    registry.register(Box::new(registered::ctx_session::CtxSessionTool));
231    registry.register(Box::new(registered::ctx_knowledge::CtxKnowledgeTool));
232    registry.register(Box::new(registered::ctx_agent::CtxAgentTool));
233    registry.register(Box::new(registered::ctx_share::CtxShareTool));
234    registry.register(Box::new(
235        registered::ctx_transcript_compact::CtxTranscriptCompactTool,
236    ));
237    registry.register(Box::new(registered::ctx_package::CtxPackageTool));
238    registry.register(Box::new(registered::ctx_handoff::CtxHandoffTool));
239    registry.register(Box::new(registered::ctx_work_graph::CtxWorkGraphTool));
240    registry.register(Box::new(registered::ctx_tasks::CtxTasksTool));
241    registry.register(Box::new(registered::ctx_load_tools::CtxLoadToolsTool));
242
243    // #509: deprecated aliases — hidden from tools/list but stay registered so
244    // direct calls and ctx_call keep working for one release cycle.
245
246    registry
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn get_arc_returns_owned_handle_for_known_tool() {
255        let registry = build_registry();
256        let arc = registry.get_arc("ctx_tree");
257        assert!(arc.is_some(), "ctx_tree must be registered");
258        assert_eq!(arc.unwrap().name(), "ctx_tree");
259    }
260
261    #[test]
262    fn work_graph_is_registered_and_publicly_advertised() {
263        let registry = build_registry();
264        assert!(registry.contains("ctx_work_graph"));
265        let tools = registry.tool_defs();
266        let tool = tools
267            .iter()
268            .find(|tool| tool.name.as_ref() == "ctx_work_graph")
269            .expect("work graph must be advertised");
270        assert_eq!(
271            tool.input_schema.get("additionalProperties"),
272            Some(&serde_json::Value::Bool(false))
273        );
274        let actions = tool.input_schema["properties"]["action"]["enum"]
275            .as_array()
276            .expect("action enum");
277        assert!(actions.iter().any(|action| action == "start"));
278        assert!(!actions.iter().any(|action| action == "execute"));
279        assert!(
280            tool.input_schema["properties"]
281                .get("execution_fence")
282                .is_some()
283        );
284        assert!(tool.input_schema["properties"].get("connector").is_none());
285    }
286
287    /// Hive metrics K2: the wire's closed tool table stays in step with the
288    /// registry (moved here from `core::hive::metrics_v2::tests`).
289    #[test]
290    fn every_registered_tool_is_a_known_wire_tool() {
291        use lean_ctx_protocol::hive::metrics_v2::KNOWN_TOOLS;
292        let missing: Vec<&str> = build_registry()
293            .names()
294            .into_iter()
295            .filter(|name| !KNOWN_TOOLS.contains(name))
296            .collect();
297        assert!(
298            missing.is_empty(),
299            "add these registered tools to metrics_v2::KNOWN_TOOLS: {missing:?}"
300        );
301    }
302
303    #[test]
304    fn get_arc_is_none_for_unknown_tool() {
305        let registry = build_registry();
306        assert!(registry.get_arc("ctx_does_not_exist_xyz").is_none());
307    }
308
309    #[test]
310    fn get_and_get_arc_agree_for_core_tool() {
311        let registry = build_registry();
312        assert_eq!(
313            registry.get("ctx_read").is_some(),
314            registry.get_arc("ctx_read").is_some(),
315            "get and get_arc must agree on tool presence"
316        );
317    }
318
319    #[test]
320    fn local_collaboration_tools_are_hidden_but_remain_directly_callable() {
321        let registry = build_registry();
322        let public_defs = registry.tool_defs();
323        let public_names: Vec<_> = public_defs.iter().map(|tool| tool.name.as_ref()).collect();
324        let power_defs =
325            registry.profile_tool_defs(&lean_ctx::core::tool_profiles::ToolProfile::Power);
326        let power_names: Vec<_> = power_defs.iter().map(|tool| tool.name.as_ref()).collect();
327        let registered_defs = registry.registered_tool_defs();
328        let registered_names: Vec<_> = registered_defs
329            .iter()
330            .map(|tool| tool.name.as_ref())
331            .collect();
332
333        for name in crate::server::dynamic_tools::LOCAL_COLLABORATION_COMPATIBILITY_TOOLS {
334            assert!(
335                !public_names.contains(name),
336                "{name} must stay out of the public tool surface"
337            );
338            assert!(
339                !power_names.contains(name),
340                "{name} must stay out of an explicit Power advertisement"
341            );
342            assert!(
343                registered_names.contains(name),
344                "{name} must remain in the direct compatibility registry"
345            );
346            assert!(
347                registry.get(name).is_some(),
348                "{name} must remain directly callable"
349            );
350        }
351    }
352}