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