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 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#[cfg(test)]
117use crate::core::pathutil::is_broad_or_unsafe_root;
118
119fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
123 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 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
191pub 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
206pub 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 76,
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}