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