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::ErrorData;
26use rmcp::handler::server::ServerHandler;
27use rmcp::model::{
28    CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
29    InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
30};
31use rmcp::service::{RequestContext, RoleServer};
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        && dir == home
50    {
51        return true;
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        // SAFETY: set during MCP `initialize` (connection bootstrap), before any
149        // tool-handler thread reads the jail allow-list via `pathjail`. The only
150        // concurrent startup tasks (proxy spawn, savings publish) never consult it.
151        unsafe { std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged) };
152        tracing::info!(
153            "Multi-root workspace detected at {}: auto-allowing {} child projects",
154            dir.display(),
155            child_projects.len()
156        );
157        return Some(dir.to_string_lossy().to_string());
158    }
159
160    None
161}
162
163pub fn tool_descriptions_for_test() -> Vec<(String, String)> {
164    crate::server::registry::build_registry()
165        .tool_defs()
166        .into_iter()
167        .map(|t| {
168            (
169                t.name.to_string(),
170                t.description.as_deref().unwrap_or("").to_string(),
171            )
172        })
173        .collect()
174}
175
176pub fn tool_schemas_json_for_test() -> String {
177    crate::server::registry::build_registry()
178        .tool_defs()
179        .iter()
180        .map(|t| {
181            format!(
182                "{}: {}",
183                t.name,
184                serde_json::to_string(&t.input_schema).unwrap_or_default()
185            )
186        })
187        .collect::<Vec<_>>()
188        .join("\n")
189}
190
191/// Tools that always pass through the workflow gate regardless of state.
192/// Read-only tools should never be blocked — agents need them for context
193/// recovery after crashes or session transitions.
194pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
195    "ctx",
196    "ctx_workflow",
197    "ctx_read",
198    "ctx_multi_read",
199    "ctx_smart_read",
200    "ctx_search",
201    "ctx_tree",
202    "ctx_session",
203    "ctx_ledger",
204];
205
206/// A workflow is stale if it hasn't been updated in 30 minutes.
207/// This prevents dead workflows from blocking tools across sessions.
208pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
209    let elapsed = chrono::Utc::now()
210        .signed_duration_since(run.updated_at)
211        .num_minutes();
212    elapsed > 30
213}
214
215fn is_shell_tool_name(name: &str) -> bool {
216    matches!(name, "ctx_shell" | "ctx_execute")
217}
218
219fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
220    let trimmed = cmd.trim();
221    let parts: Vec<&str> = trimmed.split_whitespace().collect();
222    if parts.len() < 2 {
223        return None;
224    }
225    let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
226    match bin {
227        "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
228            let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
229            Some(file_arg.to_string())
230        }
231        _ => None,
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn project_markers_detected() {
241        let tmp = tempfile::tempdir().unwrap();
242        let root = tmp.path().join("myproject");
243        std::fs::create_dir_all(&root).unwrap();
244        assert!(!has_project_marker(&root));
245
246        std::fs::create_dir(root.join(".git")).unwrap();
247        assert!(has_project_marker(&root));
248    }
249
250    #[test]
251    fn home_dir_detected_as_agent_dir() {
252        if let Some(home) = dirs::home_dir() {
253            assert!(is_home_or_agent_dir(&home));
254        }
255    }
256
257    #[test]
258    fn agent_dirs_detected() {
259        let claude = std::path::PathBuf::from("/home/user/.claude");
260        assert!(is_home_or_agent_dir(&claude));
261        let codex = std::path::PathBuf::from("/home/user/.codex");
262        assert!(is_home_or_agent_dir(&codex));
263        let project = std::path::PathBuf::from("/home/user/projects/myapp");
264        assert!(!is_home_or_agent_dir(&project));
265    }
266
267    #[test]
268    fn test_unified_tool_count() {
269        let tools = crate::tool_defs::unified_tool_defs();
270        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
271    }
272
273    #[test]
274    fn test_granular_tool_count() {
275        let tools = crate::tool_defs::granular_tool_defs();
276        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
277    }
278
279    #[test]
280    fn test_registry_tool_count_ssot() {
281        let registry = crate::server::registry::build_registry();
282        assert_eq!(
283            registry.len(),
284            77,
285            "Registry tool count drift! Update this test AND all docs when adding/removing tools."
286        );
287    }
288
289    #[test]
290    fn disabled_tools_filters_list() {
291        let all = crate::tool_defs::granular_tool_defs();
292        let total = all.len();
293        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
294        let filtered: Vec<_> = all
295            .into_iter()
296            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
297            .collect();
298        assert_eq!(filtered.len(), total - 2);
299        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
300        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
301    }
302
303    #[test]
304    fn empty_disabled_tools_returns_all() {
305        let all = crate::tool_defs::granular_tool_defs();
306        let total = all.len();
307        let disabled: Vec<String> = vec![];
308        let filtered: Vec<_> = all
309            .into_iter()
310            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
311            .collect();
312        assert_eq!(filtered.len(), total);
313    }
314
315    #[test]
316    fn misspelled_disabled_tool_is_silently_ignored() {
317        let all = crate::tool_defs::granular_tool_defs();
318        let total = all.len();
319        let disabled = ["ctx_nonexistent_tool".to_string()];
320        let filtered: Vec<_> = all
321            .into_iter()
322            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
323            .collect();
324        assert_eq!(filtered.len(), total);
325    }
326
327    #[test]
328    fn detect_multi_root_workspace_with_child_projects() {
329        let tmp = tempfile::tempdir().unwrap();
330        let workspace = tmp.path().join("workspace");
331        std::fs::create_dir_all(&workspace).unwrap();
332
333        let proj_a = workspace.join("project-a");
334        let proj_b = workspace.join("project-b");
335        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
336        std::fs::create_dir_all(&proj_b).unwrap();
337        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
338
339        let result = detect_multi_root_workspace(&workspace);
340        assert!(
341            result.is_some(),
342            "should detect workspace with 2 child projects"
343        );
344
345        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
346    }
347
348    #[test]
349    fn detect_multi_root_workspace_returns_none_for_single_project() {
350        let tmp = tempfile::tempdir().unwrap();
351        let workspace = tmp.path().join("workspace");
352        std::fs::create_dir_all(&workspace).unwrap();
353
354        let proj_a = workspace.join("project-a");
355        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
356
357        let result = detect_multi_root_workspace(&workspace);
358        assert!(
359            result.is_none(),
360            "should not detect workspace with only 1 child project"
361        );
362    }
363
364    #[test]
365    fn is_broad_or_unsafe_root_rejects_home() {
366        if let Some(home) = dirs::home_dir() {
367            assert!(is_broad_or_unsafe_root(&home));
368        }
369    }
370
371    #[test]
372    fn is_broad_or_unsafe_root_rejects_filesystem_root() {
373        assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
374    }
375
376    #[test]
377    fn is_broad_or_unsafe_root_rejects_agent_dirs() {
378        assert!(is_broad_or_unsafe_root(std::path::Path::new(
379            "/home/user/.claude"
380        )));
381        assert!(is_broad_or_unsafe_root(std::path::Path::new(
382            "/home/user/.codex"
383        )));
384    }
385
386    #[test]
387    fn is_broad_or_unsafe_root_allows_project_subdir() {
388        let tmp = tempfile::tempdir().unwrap();
389        let subdir = tmp.path().join("my-project");
390        std::fs::create_dir_all(&subdir).unwrap();
391        assert!(!is_broad_or_unsafe_root(&subdir));
392    }
393
394    #[test]
395    fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
396        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
397            "/tmp/leanctx-test"
398        )));
399        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
400            "/tmp/my-project"
401        )));
402    }
403
404    #[test]
405    fn is_broad_or_unsafe_root_allows_home_subdirs() {
406        if let Some(home) = dirs::home_dir() {
407            let subdir = home.join("projects").join("my-app");
408            assert!(!is_broad_or_unsafe_root(&subdir));
409        }
410    }
411
412    #[test]
413    fn derive_project_root_falls_back_to_bare_cwd() {
414        let tmp = tempfile::tempdir().unwrap();
415        let bare = tmp.path().join("bare-dir");
416        std::fs::create_dir_all(&bare).unwrap();
417
418        let original = std::env::current_dir().unwrap();
419        std::env::set_current_dir(&bare).unwrap();
420        let result = derive_project_root_from_cwd();
421        std::env::set_current_dir(original).unwrap();
422
423        assert!(result.is_some(), "bare dir should produce a project root");
424        let root = result.unwrap();
425        assert!(
426            root.contains("bare-dir"),
427            "fallback should use the bare dir path"
428        );
429    }
430}