Skip to main content

lean_ctx/server/
mod.rs

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