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