Skip to main content

lean_ctx/server/
mod.rs

1pub mod bounded_lock;
2pub mod bypass_hint;
3pub mod compaction_sync;
4pub mod context_gate;
5mod dispatch;
6pub mod dynamic_tools;
7pub mod elicitation;
8pub(crate) mod execute;
9pub mod helpers;
10pub mod multi_path;
11pub mod notifications;
12pub mod progress;
13pub mod prompts;
14pub mod reference_store;
15pub mod registry;
16pub mod resources;
17pub mod role_guard;
18pub mod roots;
19use roots::has_project_marker;
20pub mod tool_trait;
21
22use futures::FutureExt;
23use rmcp::handler::server::ServerHandler;
24use rmcp::model::{
25    CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
26    InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
27};
28use rmcp::service::{RequestContext, RoleServer};
29use rmcp::ErrorData;
30
31use crate::tools::{CrpMode, LeanCtxServer};
32mod call_tool;
33mod server_handler;
34
35pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
36    crate::instructions::build_instructions_for_test(crp_mode)
37}
38
39pub fn build_claude_code_instructions_for_test() -> String {
40    crate::instructions::claude_code_instructions()
41}
42
43fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
44    if let Some(home) = dirs::home_dir() {
45        if dir == home {
46            return true;
47        }
48    }
49    let dir_str = dir.to_string_lossy();
50    dir_str.ends_with("/.claude")
51        || dir_str.ends_with("/.codex")
52        || dir_str.contains("/.claude/")
53        || dir_str.contains("/.codex/")
54}
55
56fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
57    std::process::Command::new("git")
58        .args(["rev-parse", "--show-toplevel"])
59        .current_dir(dir)
60        .stdout(std::process::Stdio::piped())
61        .stderr(std::process::Stdio::null())
62        .output()
63        .ok()
64        .and_then(|o| {
65            if o.status.success() {
66                String::from_utf8(o.stdout)
67                    .ok()
68                    .map(|s| s.trim().to_string())
69            } else {
70                None
71            }
72        })
73}
74
75pub fn derive_project_root_from_cwd() -> Option<String> {
76    let cwd = std::env::current_dir().ok()?;
77    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
78
79    if is_home_or_agent_dir(&canonical) {
80        return git_toplevel_from(&canonical);
81    }
82
83    if has_project_marker(&canonical) {
84        return Some(canonical.to_string_lossy().to_string());
85    }
86
87    if let Some(git_root) = git_toplevel_from(&canonical) {
88        return Some(git_root);
89    }
90
91    if let Some(root) = detect_multi_root_workspace(&canonical) {
92        return Some(root);
93    }
94
95    // Fallback: use CWD as project root if it's a specific, safe directory.
96    // This ensures bare directories (no .git, no markers) still work.
97    // Guard: reject home dir, filesystem root, and agent sandbox dirs.
98    if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
99        tracing::info!(
100            "No project markers found — using CWD as project root: {}",
101            canonical.display()
102        );
103        return Some(canonical.to_string_lossy().to_string());
104    }
105
106    None
107}
108
109// Delegated to crate::core::pathutil::is_broad_or_unsafe_root
110#[cfg(test)]
111use crate::core::pathutil::is_broad_or_unsafe_root;
112
113/// Detect a multi-root workspace: a directory that has no project markers
114/// itself, but contains child directories that do. In this case, use the
115/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
116fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
117    let entries = std::fs::read_dir(dir).ok()?;
118    let mut child_projects: Vec<String> = Vec::new();
119
120    for entry in entries.flatten() {
121        let path = entry.path();
122        if path.is_dir() && has_project_marker(&path) {
123            let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
124            child_projects.push(canonical.to_string_lossy().to_string());
125        }
126    }
127
128    if child_projects.len() >= 2 {
129        let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
130        let sep = if cfg!(windows) { ";" } else { ":" };
131        let merged = if existing.is_empty() {
132            child_projects.join(sep)
133        } else {
134            format!("{existing}{sep}{}", child_projects.join(sep))
135        };
136        std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
137        tracing::info!(
138            "Multi-root workspace detected at {}: auto-allowing {} child projects",
139            dir.display(),
140            child_projects.len()
141        );
142        return Some(dir.to_string_lossy().to_string());
143    }
144
145    None
146}
147
148pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
149    crate::tool_defs::list_all_tool_defs()
150        .into_iter()
151        .map(|(name, desc, _)| (name, desc))
152        .collect()
153}
154
155pub fn tool_schemas_json_for_test() -> String {
156    crate::tool_defs::list_all_tool_defs()
157        .iter()
158        .map(|(name, _, schema)| format!("{name}: {schema}"))
159        .collect::<Vec<_>>()
160        .join("\n")
161}
162
163/// Tools that always pass through the workflow gate regardless of state.
164/// Read-only tools should never be blocked — agents need them for context
165/// recovery after crashes or session transitions.
166pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
167    "ctx",
168    "ctx_workflow",
169    "ctx_read",
170    "ctx_multi_read",
171    "ctx_smart_read",
172    "ctx_search",
173    "ctx_tree",
174    "ctx_session",
175    "ctx_ledger",
176];
177
178/// A workflow is stale if it hasn't been updated in 30 minutes.
179/// This prevents dead workflows from blocking tools across sessions.
180pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
181    let elapsed = chrono::Utc::now()
182        .signed_duration_since(run.updated_at)
183        .num_minutes();
184    elapsed > 30
185}
186
187fn is_shell_tool_name(name: &str) -> bool {
188    matches!(name, "ctx_shell" | "ctx_execute")
189}
190
191fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
192    let trimmed = cmd.trim();
193    let parts: Vec<&str> = trimmed.split_whitespace().collect();
194    if parts.len() < 2 {
195        return None;
196    }
197    let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
198    match bin {
199        "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
200            let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
201            Some(file_arg.to_string())
202        }
203        _ => None,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn project_markers_detected() {
213        let tmp = tempfile::tempdir().unwrap();
214        let root = tmp.path().join("myproject");
215        std::fs::create_dir_all(&root).unwrap();
216        assert!(!has_project_marker(&root));
217
218        std::fs::create_dir(root.join(".git")).unwrap();
219        assert!(has_project_marker(&root));
220    }
221
222    #[test]
223    fn home_dir_detected_as_agent_dir() {
224        if let Some(home) = dirs::home_dir() {
225            assert!(is_home_or_agent_dir(&home));
226        }
227    }
228
229    #[test]
230    fn agent_dirs_detected() {
231        let claude = std::path::PathBuf::from("/home/user/.claude");
232        assert!(is_home_or_agent_dir(&claude));
233        let codex = std::path::PathBuf::from("/home/user/.codex");
234        assert!(is_home_or_agent_dir(&codex));
235        let project = std::path::PathBuf::from("/home/user/projects/myapp");
236        assert!(!is_home_or_agent_dir(&project));
237    }
238
239    #[test]
240    fn test_unified_tool_count() {
241        let tools = crate::tool_defs::unified_tool_defs();
242        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
243    }
244
245    #[test]
246    fn test_granular_tool_count() {
247        let tools = crate::tool_defs::granular_tool_defs();
248        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
249    }
250
251    #[test]
252    fn test_registry_tool_count_ssot() {
253        let registry = crate::server::registry::build_registry();
254        assert_eq!(
255            registry.len(),
256            68,
257            "Registry tool count drift! Update this test AND all docs when adding/removing tools."
258        );
259    }
260
261    #[test]
262    fn disabled_tools_filters_list() {
263        let all = crate::tool_defs::granular_tool_defs();
264        let total = all.len();
265        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
266        let filtered: Vec<_> = all
267            .into_iter()
268            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
269            .collect();
270        assert_eq!(filtered.len(), total - 2);
271        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
272        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
273    }
274
275    #[test]
276    fn empty_disabled_tools_returns_all() {
277        let all = crate::tool_defs::granular_tool_defs();
278        let total = all.len();
279        let disabled: Vec<String> = vec![];
280        let filtered: Vec<_> = all
281            .into_iter()
282            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
283            .collect();
284        assert_eq!(filtered.len(), total);
285    }
286
287    #[test]
288    fn misspelled_disabled_tool_is_silently_ignored() {
289        let all = crate::tool_defs::granular_tool_defs();
290        let total = all.len();
291        let disabled = ["ctx_nonexistent_tool".to_string()];
292        let filtered: Vec<_> = all
293            .into_iter()
294            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
295            .collect();
296        assert_eq!(filtered.len(), total);
297    }
298
299    #[test]
300    fn detect_multi_root_workspace_with_child_projects() {
301        let tmp = tempfile::tempdir().unwrap();
302        let workspace = tmp.path().join("workspace");
303        std::fs::create_dir_all(&workspace).unwrap();
304
305        let proj_a = workspace.join("project-a");
306        let proj_b = workspace.join("project-b");
307        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
308        std::fs::create_dir_all(&proj_b).unwrap();
309        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
310
311        let result = detect_multi_root_workspace(&workspace);
312        assert!(
313            result.is_some(),
314            "should detect workspace with 2 child projects"
315        );
316
317        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
318    }
319
320    #[test]
321    fn detect_multi_root_workspace_returns_none_for_single_project() {
322        let tmp = tempfile::tempdir().unwrap();
323        let workspace = tmp.path().join("workspace");
324        std::fs::create_dir_all(&workspace).unwrap();
325
326        let proj_a = workspace.join("project-a");
327        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
328
329        let result = detect_multi_root_workspace(&workspace);
330        assert!(
331            result.is_none(),
332            "should not detect workspace with only 1 child project"
333        );
334    }
335
336    #[test]
337    fn is_broad_or_unsafe_root_rejects_home() {
338        if let Some(home) = dirs::home_dir() {
339            assert!(is_broad_or_unsafe_root(&home));
340        }
341    }
342
343    #[test]
344    fn is_broad_or_unsafe_root_rejects_filesystem_root() {
345        assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
346    }
347
348    #[test]
349    fn is_broad_or_unsafe_root_rejects_agent_dirs() {
350        assert!(is_broad_or_unsafe_root(std::path::Path::new(
351            "/home/user/.claude"
352        )));
353        assert!(is_broad_or_unsafe_root(std::path::Path::new(
354            "/home/user/.codex"
355        )));
356    }
357
358    #[test]
359    fn is_broad_or_unsafe_root_allows_project_subdir() {
360        let tmp = tempfile::tempdir().unwrap();
361        let subdir = tmp.path().join("my-project");
362        std::fs::create_dir_all(&subdir).unwrap();
363        assert!(!is_broad_or_unsafe_root(&subdir));
364    }
365
366    #[test]
367    fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
368        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
369            "/tmp/leanctx-test"
370        )));
371        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
372            "/tmp/my-project"
373        )));
374    }
375
376    #[test]
377    fn is_broad_or_unsafe_root_allows_home_subdirs() {
378        if let Some(home) = dirs::home_dir() {
379            let subdir = home.join("projects").join("my-app");
380            assert!(!is_broad_or_unsafe_root(&subdir));
381        }
382    }
383
384    #[test]
385    fn derive_project_root_falls_back_to_bare_cwd() {
386        let tmp = tempfile::tempdir().unwrap();
387        let bare = tmp.path().join("bare-dir");
388        std::fs::create_dir_all(&bare).unwrap();
389
390        let original = std::env::current_dir().unwrap();
391        std::env::set_current_dir(&bare).unwrap();
392        let result = derive_project_root_from_cwd();
393        std::env::set_current_dir(original).unwrap();
394
395        assert!(result.is_some(), "bare dir should produce a project root");
396        let root = result.unwrap();
397        assert!(
398            root.contains("bare-dir"),
399            "fallback should use the bare dir path"
400        );
401    }
402}