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