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::handler::server::ServerHandler;
26use rmcp::model::{
27 CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
28 InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
29};
30use rmcp::service::{RequestContext, RoleServer};
31use rmcp::ErrorData;
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 if dir == home {
50 return true;
51 }
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 std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
149 tracing::info!(
150 "Multi-root workspace detected at {}: auto-allowing {} child projects",
151 dir.display(),
152 child_projects.len()
153 );
154 return Some(dir.to_string_lossy().to_string());
155 }
156
157 None
158}
159
160pub fn tool_descriptions_for_test() -> Vec<(String, String)> {
161 crate::server::registry::build_registry()
162 .tool_defs()
163 .into_iter()
164 .map(|t| {
165 (
166 t.name.to_string(),
167 t.description.as_deref().unwrap_or("").to_string(),
168 )
169 })
170 .collect()
171}
172
173pub fn tool_schemas_json_for_test() -> String {
174 crate::server::registry::build_registry()
175 .tool_defs()
176 .iter()
177 .map(|t| {
178 format!(
179 "{}: {}",
180 t.name,
181 serde_json::to_string(&t.input_schema).unwrap_or_default()
182 )
183 })
184 .collect::<Vec<_>>()
185 .join("\n")
186}
187
188pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
192 "ctx",
193 "ctx_workflow",
194 "ctx_read",
195 "ctx_multi_read",
196 "ctx_smart_read",
197 "ctx_search",
198 "ctx_tree",
199 "ctx_session",
200 "ctx_ledger",
201];
202
203pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
206 let elapsed = chrono::Utc::now()
207 .signed_duration_since(run.updated_at)
208 .num_minutes();
209 elapsed > 30
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_agent_dir() {
249 if let Some(home) = dirs::home_dir() {
250 assert!(is_home_or_agent_dir(&home));
251 }
252 }
253
254 #[test]
255 fn agent_dirs_detected() {
256 let claude = std::path::PathBuf::from("/home/user/.claude");
257 assert!(is_home_or_agent_dir(&claude));
258 let codex = std::path::PathBuf::from("/home/user/.codex");
259 assert!(is_home_or_agent_dir(&codex));
260 let project = std::path::PathBuf::from("/home/user/projects/myapp");
261 assert!(!is_home_or_agent_dir(&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 76,
282 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
283 );
284 }
285
286 #[test]
287 fn disabled_tools_filters_list() {
288 let all = crate::tool_defs::granular_tool_defs();
289 let total = all.len();
290 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
291 let filtered: Vec<_> = all
292 .into_iter()
293 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
294 .collect();
295 assert_eq!(filtered.len(), total - 2);
296 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
297 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
298 }
299
300 #[test]
301 fn empty_disabled_tools_returns_all() {
302 let all = crate::tool_defs::granular_tool_defs();
303 let total = all.len();
304 let disabled: Vec<String> = vec![];
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);
310 }
311
312 #[test]
313 fn misspelled_disabled_tool_is_silently_ignored() {
314 let all = crate::tool_defs::granular_tool_defs();
315 let total = all.len();
316 let disabled = ["ctx_nonexistent_tool".to_string()];
317 let filtered: Vec<_> = all
318 .into_iter()
319 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
320 .collect();
321 assert_eq!(filtered.len(), total);
322 }
323
324 #[test]
325 fn detect_multi_root_workspace_with_child_projects() {
326 let tmp = tempfile::tempdir().unwrap();
327 let workspace = tmp.path().join("workspace");
328 std::fs::create_dir_all(&workspace).unwrap();
329
330 let proj_a = workspace.join("project-a");
331 let proj_b = workspace.join("project-b");
332 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
333 std::fs::create_dir_all(&proj_b).unwrap();
334 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
335
336 let result = detect_multi_root_workspace(&workspace);
337 assert!(
338 result.is_some(),
339 "should detect workspace with 2 child projects"
340 );
341
342 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
343 }
344
345 #[test]
346 fn detect_multi_root_workspace_returns_none_for_single_project() {
347 let tmp = tempfile::tempdir().unwrap();
348 let workspace = tmp.path().join("workspace");
349 std::fs::create_dir_all(&workspace).unwrap();
350
351 let proj_a = workspace.join("project-a");
352 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
353
354 let result = detect_multi_root_workspace(&workspace);
355 assert!(
356 result.is_none(),
357 "should not detect workspace with only 1 child project"
358 );
359 }
360
361 #[test]
362 fn is_broad_or_unsafe_root_rejects_home() {
363 if let Some(home) = dirs::home_dir() {
364 assert!(is_broad_or_unsafe_root(&home));
365 }
366 }
367
368 #[test]
369 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
370 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
371 }
372
373 #[test]
374 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
375 assert!(is_broad_or_unsafe_root(std::path::Path::new(
376 "/home/user/.claude"
377 )));
378 assert!(is_broad_or_unsafe_root(std::path::Path::new(
379 "/home/user/.codex"
380 )));
381 }
382
383 #[test]
384 fn is_broad_or_unsafe_root_allows_project_subdir() {
385 let tmp = tempfile::tempdir().unwrap();
386 let subdir = tmp.path().join("my-project");
387 std::fs::create_dir_all(&subdir).unwrap();
388 assert!(!is_broad_or_unsafe_root(&subdir));
389 }
390
391 #[test]
392 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
393 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
394 "/tmp/leanctx-test"
395 )));
396 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
397 "/tmp/my-project"
398 )));
399 }
400
401 #[test]
402 fn is_broad_or_unsafe_root_allows_home_subdirs() {
403 if let Some(home) = dirs::home_dir() {
404 let subdir = home.join("projects").join("my-app");
405 assert!(!is_broad_or_unsafe_root(&subdir));
406 }
407 }
408
409 #[test]
410 fn derive_project_root_falls_back_to_bare_cwd() {
411 let tmp = tempfile::tempdir().unwrap();
412 let bare = tmp.path().join("bare-dir");
413 std::fs::create_dir_all(&bare).unwrap();
414
415 let original = std::env::current_dir().unwrap();
416 std::env::set_current_dir(&bare).unwrap();
417 let result = derive_project_root_from_cwd();
418 std::env::set_current_dir(original).unwrap();
419
420 assert!(result.is_some(), "bare dir should produce a project root");
421 let root = result.unwrap();
422 assert!(
423 root.contains("bare-dir"),
424 "fallback should use the bare dir path"
425 );
426 }
427}