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_shell_tool_name(name: &str) -> bool {
205 matches!(name, "ctx_shell" | "ctx_execute")
206}
207
208fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
209 let trimmed = cmd.trim();
210 let parts: Vec<&str> = trimmed.split_whitespace().collect();
211 if parts.len() < 2 {
212 return None;
213 }
214 let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
215 match bin {
216 "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
217 let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
218 Some(file_arg.to_string())
219 }
220 _ => None,
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn project_markers_detected() {
230 let tmp = tempfile::tempdir().unwrap();
231 let root = tmp.path().join("myproject");
232 std::fs::create_dir_all(&root).unwrap();
233 assert!(!has_project_marker(&root));
234
235 std::fs::create_dir(root.join(".git")).unwrap();
236 assert!(has_project_marker(&root));
237 }
238
239 #[test]
240 fn home_dir_detected_as_broad_root() {
241 if let Some(home) = dirs::home_dir() {
242 assert!(is_broad_or_unsafe_root(&home));
243 }
244 }
245
246 #[test]
247 fn agent_dirs_detected() {
248 let claude = std::path::PathBuf::from("/home/user/.claude");
249 assert!(is_broad_or_unsafe_root(&claude));
250 let codex = std::path::PathBuf::from("/home/user/.codex");
251 assert!(is_broad_or_unsafe_root(&codex));
252 let project = std::path::PathBuf::from("/home/user/projects/myapp");
253 assert!(!is_broad_or_unsafe_root(&project));
254 }
255
256 #[test]
257 fn test_unified_tool_count() {
258 let tools = crate::tool_defs::unified_tool_defs();
259 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
260 }
261
262 #[test]
263 fn test_granular_tool_count() {
264 let tools = crate::tool_defs::granular_tool_defs();
265 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
266 }
267
268 #[test]
269 fn test_registry_tool_count_ssot() {
270 let registry = crate::server::registry::build_registry();
271 assert_eq!(
272 registry.len(),
273 82,
274 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
275 );
276 }
277
278 #[test]
279 fn production_server_always_has_registry() {
280 let server = crate::tools::create_server();
286 assert!(
287 server.registry.is_some(),
288 "production server must carry a tool registry"
289 );
290 }
291
292 #[test]
293 fn disabled_tools_filters_list() {
294 let all = crate::tool_defs::granular_tool_defs();
295 let total = all.len();
296 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
297 let filtered: Vec<_> = all
298 .into_iter()
299 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
300 .collect();
301 assert_eq!(filtered.len(), total - 2);
302 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
303 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
304 }
305
306 #[test]
307 fn empty_disabled_tools_returns_all() {
308 let all = crate::tool_defs::granular_tool_defs();
309 let total = all.len();
310 let disabled: Vec<String> = vec![];
311 let filtered: Vec<_> = all
312 .into_iter()
313 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
314 .collect();
315 assert_eq!(filtered.len(), total);
316 }
317
318 #[test]
319 fn misspelled_disabled_tool_is_silently_ignored() {
320 let all = crate::tool_defs::granular_tool_defs();
321 let total = all.len();
322 let disabled = ["ctx_nonexistent_tool".to_string()];
323 let filtered: Vec<_> = all
324 .into_iter()
325 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
326 .collect();
327 assert_eq!(filtered.len(), total);
328 }
329
330 #[test]
331 fn detect_multi_root_workspace_with_child_projects() {
332 let _guard = crate::core::data_dir::test_env_lock();
337 let tmp = tempfile::tempdir().unwrap();
338 let workspace = tmp.path().join("workspace");
339 std::fs::create_dir_all(&workspace).unwrap();
340
341 let proj_a = workspace.join("project-a");
342 let proj_b = workspace.join("project-b");
343 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
344 std::fs::create_dir_all(&proj_b).unwrap();
345 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
346
347 let result = detect_multi_root_workspace(&workspace);
348 assert!(
349 result.is_some(),
350 "should detect workspace with 2 child projects"
351 );
352
353 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
354 }
355
356 #[test]
357 fn detect_multi_root_workspace_returns_none_for_single_project() {
358 let tmp = tempfile::tempdir().unwrap();
359 let workspace = tmp.path().join("workspace");
360 std::fs::create_dir_all(&workspace).unwrap();
361
362 let proj_a = workspace.join("project-a");
363 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
364
365 let result = detect_multi_root_workspace(&workspace);
366 assert!(
367 result.is_none(),
368 "should not detect workspace with only 1 child project"
369 );
370 }
371
372 #[test]
373 fn is_broad_or_unsafe_root_rejects_home() {
374 if let Some(home) = dirs::home_dir() {
375 assert!(is_broad_or_unsafe_root(&home));
376 }
377 }
378
379 #[test]
380 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
381 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
382 }
383
384 #[test]
385 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
386 assert!(is_broad_or_unsafe_root(std::path::Path::new(
387 "/home/user/.claude"
388 )));
389 assert!(is_broad_or_unsafe_root(std::path::Path::new(
390 "/home/user/.codex"
391 )));
392 }
393
394 #[test]
395 fn is_broad_or_unsafe_root_allows_project_subdir() {
396 let tmp = tempfile::tempdir().unwrap();
397 let subdir = tmp.path().join("my-project");
398 std::fs::create_dir_all(&subdir).unwrap();
399 assert!(!is_broad_or_unsafe_root(&subdir));
400 }
401
402 #[test]
403 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
404 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
405 "/tmp/leanctx-test"
406 )));
407 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
408 "/tmp/my-project"
409 )));
410 }
411
412 #[test]
413 fn is_broad_or_unsafe_root_allows_home_subdirs() {
414 if let Some(home) = dirs::home_dir() {
415 let subdir = home.join("projects").join("my-app");
416 assert!(!is_broad_or_unsafe_root(&subdir));
417 }
418 }
419
420 #[test]
421 fn derive_project_root_falls_back_to_bare_cwd() {
422 let tmp = tempfile::tempdir().unwrap();
423 let bare = tmp.path().join("bare-dir");
424 std::fs::create_dir_all(&bare).unwrap();
425
426 let original = std::env::current_dir().unwrap();
427 std::env::set_current_dir(&bare).unwrap();
428 let result = derive_project_root_from_cwd();
429 std::env::set_current_dir(original).unwrap();
430
431 assert!(result.is_some(), "bare dir should produce a project root");
432 let root = result.unwrap();
433 assert!(
434 root.contains("bare-dir"),
435 "fallback should use the bare dir path"
436 );
437 }
438}