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
52pub 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 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#[cfg(test)]
113use crate::core::pathutil::is_broad_or_unsafe_root;
114
115fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
119 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
180pub 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
195pub 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
204fn 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 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 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}