Skip to main content

lean_ctx_server/server/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2pub(crate) mod activity;
3pub mod background_shell;
4pub use lean_ctx::core::bounded_lock;
5pub use lean_ctx::core::bypass_hint;
6mod call_identity;
7#[cfg(test)]
8mod catalog_tests;
9pub mod compaction_sync;
10pub mod context_gate;
11#[cfg(test)]
12mod decision_loop_integration_tests;
13mod dispatch;
14pub use dispatch::canonicalize_arg_aliases;
15pub mod dynamic_tools;
16pub mod elicitation;
17pub(crate) mod execute;
18#[cfg(unix)]
19pub use execute::register_protected_shell_runner;
20mod file_resource;
21pub mod helpers;
22#[cfg(any(test, feature = "test-support"))]
23pub mod isolation_fixture;
24pub mod mcp_idle;
25pub mod multi_path;
26pub mod native_receipts;
27pub mod notifications;
28pub mod permission_inheritance;
29pub mod policy_guard;
30pub mod predictive_preload;
31pub mod progress;
32pub mod prompts;
33pub use lean_ctx::core::reference_store;
34pub mod registry;
35pub mod resources;
36pub mod role_guard;
37pub use lean_ctx::core::mcp_roots as roots;
38pub mod schema_hook;
39pub(crate) mod shell_approval;
40pub use lean_ctx::core::mcp_roots::{derive_project_root_from, derive_project_root_from_cwd};
41use lean_ctx::core::pathutil::has_project_marker;
42pub mod tool_trait;
43pub mod tool_visibility;
44pub mod tools_config_watch;
45pub(crate) mod walk_hint;
46// Workspace authority verifies file ownership, which needs a Unix host.
47#[cfg(all(test, unix))]
48mod workspace_roots_tests;
49
50use futures::FutureExt;
51use rmcp::ErrorData;
52use rmcp::handler::server::ServerHandler;
53use rmcp::model::{
54    CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
55    InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig,
56};
57use rmcp::service::{RequestContext, RoleServer};
58
59use crate::tools::{CrpMode, LeanCtxServer};
60mod call_tool;
61pub(crate) use call_tool::SessionSwitch;
62mod post_dispatch;
63mod post_process;
64mod server_handler;
65
66pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
67    lean_ctx::instructions::build_instructions_for_test(crp_mode)
68}
69
70pub fn build_claude_code_instructions_for_test() -> String {
71    lean_ctx::instructions::claude_code_instructions()
72}
73
74/// Deterministic STATIC Claude Code instructions (cold first-contact, no dynamic
75/// session/knowledge/gotcha payload) for the char-budget benchmark.
76pub fn build_claude_code_static_instructions_for_test() -> String {
77    lean_ctx::instructions::claude_code_static_instructions_for_test()
78}
79
80// Delegated to lean_ctx::core::pathutil::is_broad_or_unsafe_root
81#[cfg(test)]
82use lean_ctx::core::pathutil::is_broad_or_unsafe_root;
83
84pub fn tool_descriptions_for_test() -> Vec<(String, String)> {
85    crate::server::registry::build_registry()
86        .tool_defs()
87        .into_iter()
88        .map(|t| {
89            (
90                t.name.to_string(),
91                t.description.as_deref().unwrap_or("").to_string(),
92            )
93        })
94        .collect()
95}
96
97pub fn tool_schemas_json_for_test() -> String {
98    crate::server::registry::build_registry()
99        .tool_defs()
100        .iter()
101        .map(|t| {
102            format!(
103                "{}: {}",
104                t.name,
105                serde_json::to_string(&t.input_schema).unwrap_or_default()
106            )
107        })
108        .collect::<Vec<_>>()
109        .join("\n")
110}
111
112pub use lean_ctx::core::workflow::WORKFLOW_PASSTHROUGH_TOOLS;
113
114/// A workflow is stale if it hasn't been updated in 30 minutes.
115/// This prevents dead workflows from blocking tools across sessions.
116///
117/// The rule itself is `core::workflow::is_run_stale` (shared with the store
118/// and the tool gate): the stand-in for a withheld workflow is never stale.
119pub fn is_workflow_stale(run: &lean_ctx::core::workflow::types::WorkflowRun) -> bool {
120    lean_ctx::core::workflow::is_run_stale(run)
121}
122
123/// Whether the MCP client has lean-ctx hooks installed that compress native
124/// Read/Shell/Grep/Glob transparently. Used by `list_tools` to decide whether
125/// the shadow-only surface (just `ctx_call`) is safe.
126fn is_client_hook_covered(client_name: &str) -> bool {
127    lean_ctx::core::home::resolve_home_dir()
128        .is_some_and(|home| lean_ctx::core::rules_channel::client_hook_covered(client_name, &home))
129}
130
131fn is_shell_tool_name(name: &str) -> bool {
132    matches!(name, "ctx_shell" | "ctx_execute")
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn home_dir_detected_as_broad_root() {
141        // HOME is read here and again inside the check: another test may move
142        // it in between (macOS release prebuild, 3.12.1).
143        let _env = lean_ctx::core::data_dir::test_env_lock();
144        if let Some(home) = dirs::home_dir() {
145            assert!(is_broad_or_unsafe_root(&home));
146        }
147    }
148
149    #[test]
150    fn agent_dirs_detected() {
151        let claude = std::path::PathBuf::from("/home/user/.claude");
152        assert!(is_broad_or_unsafe_root(&claude));
153        let codex = std::path::PathBuf::from("/home/user/.codex");
154        assert!(is_broad_or_unsafe_root(&codex));
155        let project = std::path::PathBuf::from("/home/user/projects/myapp");
156        assert!(!is_broad_or_unsafe_root(&project));
157    }
158
159    #[test]
160    fn test_unified_tool_count() {
161        let tools = lean_ctx::tool_defs::unified_tool_defs();
162        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
163    }
164
165    #[test]
166    fn test_granular_tool_count() {
167        registry::register_tool_catalog();
168        let tools = lean_ctx::tool_defs::granular_tool_defs();
169        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
170    }
171
172    #[test]
173    fn production_server_always_has_registry() {
174        // The list_tools fallback that serves static defs when `registry` is None
175        // must stay unreachable in production: every public constructor funnels
176        // through new_with_startup, which sets registry = Some. Lock that invariant
177        // so the advertised tool set can never silently drift from what dispatch
178        // (which requires the registry) can actually execute.
179        let server = crate::tools::create_server();
180        assert!(
181            server.registry.is_some(),
182            "production server must carry a tool registry"
183        );
184    }
185
186    #[test]
187    fn disabled_tools_filters_list() {
188        registry::register_tool_catalog();
189        let all = lean_ctx::tool_defs::granular_tool_defs();
190        let total = all.len();
191        // `ctx_agent` is retained as a direct compatibility path but is no
192        // longer part of the advertised granular surface. Exercise filtering
193        // with one actual public tool instead of treating a hidden alias as
194        // visible inventory.
195        let disabled = ["ctx_graph".to_string()];
196        let filtered: Vec<_> = all
197            .into_iter()
198            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
199            .collect();
200        assert_eq!(filtered.len(), total - 1);
201        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
202        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
203    }
204
205    #[test]
206    fn empty_disabled_tools_returns_all() {
207        let all = lean_ctx::tool_defs::granular_tool_defs();
208        let total = all.len();
209        let disabled: Vec<String> = vec![];
210        let filtered: Vec<_> = all
211            .into_iter()
212            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
213            .collect();
214        assert_eq!(filtered.len(), total);
215    }
216
217    #[test]
218    fn misspelled_disabled_tool_is_silently_ignored() {
219        let all = lean_ctx::tool_defs::granular_tool_defs();
220        let total = all.len();
221        let disabled = ["ctx_nonexistent_tool".to_string()];
222        let filtered: Vec<_> = all
223            .into_iter()
224            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
225            .collect();
226        assert_eq!(filtered.len(), total);
227    }
228
229    #[test]
230    fn is_broad_or_unsafe_root_rejects_home() {
231        // HOME is read here and again inside the check: another test may move
232        // it in between (macOS release prebuild, 3.12.1).
233        let _env = lean_ctx::core::data_dir::test_env_lock();
234        if let Some(home) = dirs::home_dir() {
235            assert!(is_broad_or_unsafe_root(&home));
236        }
237    }
238
239    #[test]
240    fn is_broad_or_unsafe_root_rejects_filesystem_root() {
241        assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
242    }
243
244    #[test]
245    fn is_broad_or_unsafe_root_rejects_agent_dirs() {
246        assert!(is_broad_or_unsafe_root(std::path::Path::new(
247            "/home/user/.claude"
248        )));
249        assert!(is_broad_or_unsafe_root(std::path::Path::new(
250            "/home/user/.codex"
251        )));
252    }
253
254    #[test]
255    fn is_broad_or_unsafe_root_allows_project_subdir() {
256        let tmp = tempfile::tempdir().unwrap();
257        let subdir = tmp.path().join("my-project");
258        std::fs::create_dir_all(&subdir).unwrap();
259        assert!(!is_broad_or_unsafe_root(&subdir));
260    }
261
262    #[test]
263    fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
264        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
265            "/tmp/leanctx-test"
266        )));
267        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
268            "/tmp/my-project"
269        )));
270    }
271
272    #[test]
273    fn is_broad_or_unsafe_root_allows_home_subdirs() {
274        // HOME is read here and again inside the check: another test may move
275        // it in between (macOS release prebuild, 3.12.1).
276        let _env = lean_ctx::core::data_dir::test_env_lock();
277        if let Some(home) = dirs::home_dir() {
278            let subdir = home.join("projects").join("my-app");
279            assert!(!is_broad_or_unsafe_root(&subdir));
280        }
281    }
282}