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("/.codex")
56 || dir_str.contains("/.claude/")
57 || dir_str.contains("/.codex/")
58}
59
60fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
61 std::process::Command::new("git")
62 .args(["rev-parse", "--show-toplevel"])
63 .current_dir(dir)
64 .stdout(std::process::Stdio::piped())
65 .stderr(std::process::Stdio::null())
66 .output()
67 .ok()
68 .and_then(|o| {
69 if o.status.success() {
70 String::from_utf8(o.stdout)
71 .ok()
72 .map(|s| s.trim().to_string())
73 } else {
74 None
75 }
76 })
77}
78
79pub fn derive_project_root_from_cwd() -> Option<String> {
80 let cwd = std::env::current_dir().ok()?;
81 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
82
83 if is_home_or_agent_dir(&canonical) {
84 return git_toplevel_from(&canonical);
85 }
86
87 if has_project_marker(&canonical) {
88 return Some(canonical.to_string_lossy().to_string());
89 }
90
91 if let Some(git_root) = git_toplevel_from(&canonical) {
92 return Some(git_root);
93 }
94
95 if let Some(root) = detect_multi_root_workspace(&canonical) {
96 return Some(root);
97 }
98
99 if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
103 tracing::info!(
104 "No project markers found — using CWD as project root: {}",
105 canonical.display()
106 );
107 return Some(canonical.to_string_lossy().to_string());
108 }
109
110 None
111}
112
113#[cfg(test)]
115use crate::core::pathutil::is_broad_or_unsafe_root;
116
117fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
121 if crate::core::pathutil::is_tcc_sensitive_home_dir(dir) {
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 let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
140 let sep = if cfg!(windows) { ";" } else { ":" };
141 let merged = if existing.is_empty() {
142 child_projects.join(sep)
143 } else {
144 format!("{existing}{sep}{}", child_projects.join(sep))
145 };
146 std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
147 tracing::info!(
148 "Multi-root workspace detected at {}: auto-allowing {} child projects",
149 dir.display(),
150 child_projects.len()
151 );
152 return Some(dir.to_string_lossy().to_string());
153 }
154
155 None
156}
157
158pub fn tool_descriptions_for_test() -> Vec<(String, String)> {
159 crate::server::registry::build_registry()
160 .tool_defs()
161 .into_iter()
162 .map(|t| {
163 (
164 t.name.to_string(),
165 t.description.as_deref().unwrap_or("").to_string(),
166 )
167 })
168 .collect()
169}
170
171pub fn tool_schemas_json_for_test() -> String {
172 crate::server::registry::build_registry()
173 .tool_defs()
174 .iter()
175 .map(|t| {
176 format!(
177 "{}: {}",
178 t.name,
179 serde_json::to_string(&t.input_schema).unwrap_or_default()
180 )
181 })
182 .collect::<Vec<_>>()
183 .join("\n")
184}
185
186pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
190 "ctx",
191 "ctx_workflow",
192 "ctx_read",
193 "ctx_multi_read",
194 "ctx_smart_read",
195 "ctx_search",
196 "ctx_tree",
197 "ctx_session",
198 "ctx_ledger",
199];
200
201pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
204 let elapsed = chrono::Utc::now()
205 .signed_duration_since(run.updated_at)
206 .num_minutes();
207 elapsed > 30
208}
209
210fn is_shell_tool_name(name: &str) -> bool {
211 matches!(name, "ctx_shell" | "ctx_execute")
212}
213
214fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
215 let trimmed = cmd.trim();
216 let parts: Vec<&str> = trimmed.split_whitespace().collect();
217 if parts.len() < 2 {
218 return None;
219 }
220 let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
221 match bin {
222 "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
223 let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
224 Some(file_arg.to_string())
225 }
226 _ => None,
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn project_markers_detected() {
236 let tmp = tempfile::tempdir().unwrap();
237 let root = tmp.path().join("myproject");
238 std::fs::create_dir_all(&root).unwrap();
239 assert!(!has_project_marker(&root));
240
241 std::fs::create_dir(root.join(".git")).unwrap();
242 assert!(has_project_marker(&root));
243 }
244
245 #[test]
246 fn home_dir_detected_as_agent_dir() {
247 if let Some(home) = dirs::home_dir() {
248 assert!(is_home_or_agent_dir(&home));
249 }
250 }
251
252 #[test]
253 fn agent_dirs_detected() {
254 let claude = std::path::PathBuf::from("/home/user/.claude");
255 assert!(is_home_or_agent_dir(&claude));
256 let codex = std::path::PathBuf::from("/home/user/.codex");
257 assert!(is_home_or_agent_dir(&codex));
258 let project = std::path::PathBuf::from("/home/user/projects/myapp");
259 assert!(!is_home_or_agent_dir(&project));
260 }
261
262 #[test]
263 fn test_unified_tool_count() {
264 let tools = crate::tool_defs::unified_tool_defs();
265 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
266 }
267
268 #[test]
269 fn test_granular_tool_count() {
270 let tools = crate::tool_defs::granular_tool_defs();
271 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
272 }
273
274 #[test]
275 fn test_registry_tool_count_ssot() {
276 let registry = crate::server::registry::build_registry();
277 assert_eq!(
278 registry.len(),
279 68,
280 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
281 );
282 }
283
284 #[test]
285 fn disabled_tools_filters_list() {
286 let all = crate::tool_defs::granular_tool_defs();
287 let total = all.len();
288 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
289 let filtered: Vec<_> = all
290 .into_iter()
291 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
292 .collect();
293 assert_eq!(filtered.len(), total - 2);
294 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
295 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
296 }
297
298 #[test]
299 fn empty_disabled_tools_returns_all() {
300 let all = crate::tool_defs::granular_tool_defs();
301 let total = all.len();
302 let disabled: Vec<String> = vec![];
303 let filtered: Vec<_> = all
304 .into_iter()
305 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
306 .collect();
307 assert_eq!(filtered.len(), total);
308 }
309
310 #[test]
311 fn misspelled_disabled_tool_is_silently_ignored() {
312 let all = crate::tool_defs::granular_tool_defs();
313 let total = all.len();
314 let disabled = ["ctx_nonexistent_tool".to_string()];
315 let filtered: Vec<_> = all
316 .into_iter()
317 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
318 .collect();
319 assert_eq!(filtered.len(), total);
320 }
321
322 #[test]
323 fn detect_multi_root_workspace_with_child_projects() {
324 let tmp = tempfile::tempdir().unwrap();
325 let workspace = tmp.path().join("workspace");
326 std::fs::create_dir_all(&workspace).unwrap();
327
328 let proj_a = workspace.join("project-a");
329 let proj_b = workspace.join("project-b");
330 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
331 std::fs::create_dir_all(&proj_b).unwrap();
332 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
333
334 let result = detect_multi_root_workspace(&workspace);
335 assert!(
336 result.is_some(),
337 "should detect workspace with 2 child projects"
338 );
339
340 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
341 }
342
343 #[test]
344 fn detect_multi_root_workspace_returns_none_for_single_project() {
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 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
351
352 let result = detect_multi_root_workspace(&workspace);
353 assert!(
354 result.is_none(),
355 "should not detect workspace with only 1 child project"
356 );
357 }
358
359 #[test]
360 fn is_broad_or_unsafe_root_rejects_home() {
361 if let Some(home) = dirs::home_dir() {
362 assert!(is_broad_or_unsafe_root(&home));
363 }
364 }
365
366 #[test]
367 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
368 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
369 }
370
371 #[test]
372 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
373 assert!(is_broad_or_unsafe_root(std::path::Path::new(
374 "/home/user/.claude"
375 )));
376 assert!(is_broad_or_unsafe_root(std::path::Path::new(
377 "/home/user/.codex"
378 )));
379 }
380
381 #[test]
382 fn is_broad_or_unsafe_root_allows_project_subdir() {
383 let tmp = tempfile::tempdir().unwrap();
384 let subdir = tmp.path().join("my-project");
385 std::fs::create_dir_all(&subdir).unwrap();
386 assert!(!is_broad_or_unsafe_root(&subdir));
387 }
388
389 #[test]
390 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
391 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
392 "/tmp/leanctx-test"
393 )));
394 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
395 "/tmp/my-project"
396 )));
397 }
398
399 #[test]
400 fn is_broad_or_unsafe_root_allows_home_subdirs() {
401 if let Some(home) = dirs::home_dir() {
402 let subdir = home.join("projects").join("my-app");
403 assert!(!is_broad_or_unsafe_root(&subdir));
404 }
405 }
406
407 #[test]
408 fn derive_project_root_falls_back_to_bare_cwd() {
409 let tmp = tempfile::tempdir().unwrap();
410 let bare = tmp.path().join("bare-dir");
411 std::fs::create_dir_all(&bare).unwrap();
412
413 let original = std::env::current_dir().unwrap();
414 std::env::set_current_dir(&bare).unwrap();
415 let result = derive_project_root_from_cwd();
416 std::env::set_current_dir(original).unwrap();
417
418 assert!(result.is_some(), "bare dir should produce a project root");
419 let root = result.unwrap();
420 assert!(
421 root.contains("bare-dir"),
422 "fallback should use the bare dir path"
423 );
424 }
425}