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