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 policy_guard;
14pub mod progress;
15pub mod prompts;
16pub mod reference_store;
17pub mod registry;
18pub mod resources;
19pub mod role_guard;
20pub mod roots;
21use roots::has_project_marker;
22pub mod tool_trait;
23pub mod tool_visibility;
24pub mod tools_config_watch;
25
26use futures::FutureExt;
27use rmcp::ErrorData;
28use rmcp::handler::server::ServerHandler;
29use rmcp::model::{
30 CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
31 InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
32};
33use rmcp::service::{RequestContext, RoleServer};
34
35use crate::tools::{CrpMode, LeanCtxServer};
36mod call_tool;
37mod post_dispatch;
38mod post_process;
39mod server_handler;
40
41pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
42 crate::instructions::build_instructions_for_test(crp_mode)
43}
44
45pub fn build_claude_code_instructions_for_test() -> String {
46 crate::instructions::claude_code_instructions()
47}
48
49pub fn build_claude_code_static_instructions_for_test() -> String {
52 crate::instructions::claude_code_static_instructions_for_test()
53}
54
55fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
56 if let Some(home) = dirs::home_dir()
57 && dir == home
58 {
59 return true;
60 }
61 crate::core::pathutil::is_agent_config_dir(dir)
62}
63
64fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
65 std::process::Command::new("git")
66 .args(["rev-parse", "--show-toplevel"])
67 .current_dir(dir)
68 .stdout(std::process::Stdio::piped())
69 .stderr(std::process::Stdio::null())
70 .output()
71 .ok()
72 .and_then(|o| {
73 if o.status.success() {
74 String::from_utf8(o.stdout)
75 .ok()
76 .map(|s| s.trim().to_string())
77 } else {
78 None
79 }
80 })
81}
82
83pub fn derive_project_root_from_cwd() -> Option<String> {
84 let cwd = std::env::current_dir().ok()?;
85 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
86
87 if is_home_or_agent_dir(&canonical) {
88 return git_toplevel_from(&canonical);
89 }
90
91 if has_project_marker(&canonical) {
92 return Some(canonical.to_string_lossy().to_string());
93 }
94
95 if let Some(git_root) = git_toplevel_from(&canonical) {
96 return Some(git_root);
97 }
98
99 if let Some(root) = detect_multi_root_workspace(&canonical) {
100 return Some(root);
101 }
102
103 if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
107 tracing::info!(
108 "No project markers found — using CWD as project root: {}",
109 canonical.display()
110 );
111 return Some(canonical.to_string_lossy().to_string());
112 }
113
114 None
115}
116
117#[cfg(test)]
119use crate::core::pathutil::is_broad_or_unsafe_root;
120
121fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
125 if crate::core::pathutil::is_tcc_sensitive_home_dir(dir) {
129 return None;
130 }
131 let entries = std::fs::read_dir(dir).ok()?;
132 let mut child_projects: Vec<String> = Vec::new();
133
134 for entry in entries.flatten() {
135 let path = entry.path();
136 if path.is_dir() && has_project_marker(&path) {
137 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
138 child_projects.push(canonical.to_string_lossy().to_string());
139 }
140 }
141
142 if child_projects.len() >= 2 {
143 let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
144 let sep = if cfg!(windows) { ";" } else { ":" };
145 let merged = if existing.is_empty() {
146 child_projects.join(sep)
147 } else {
148 format!("{existing}{sep}{}", child_projects.join(sep))
149 };
150 unsafe { std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged) };
154 tracing::info!(
155 "Multi-root workspace detected at {}: auto-allowing {} child projects",
156 dir.display(),
157 child_projects.len()
158 );
159 return Some(dir.to_string_lossy().to_string());
160 }
161
162 None
163}
164
165pub fn tool_descriptions_for_test() -> Vec<(String, String)> {
166 crate::server::registry::build_registry()
167 .tool_defs()
168 .into_iter()
169 .map(|t| {
170 (
171 t.name.to_string(),
172 t.description.as_deref().unwrap_or("").to_string(),
173 )
174 })
175 .collect()
176}
177
178pub fn tool_schemas_json_for_test() -> String {
179 crate::server::registry::build_registry()
180 .tool_defs()
181 .iter()
182 .map(|t| {
183 format!(
184 "{}: {}",
185 t.name,
186 serde_json::to_string(&t.input_schema).unwrap_or_default()
187 )
188 })
189 .collect::<Vec<_>>()
190 .join("\n")
191}
192
193pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
197 "ctx",
198 "ctx_workflow",
199 "ctx_read",
200 "ctx_multi_read",
201 "ctx_smart_read",
202 "ctx_search",
203 "ctx_tree",
204 "ctx_session",
205 "ctx_ledger",
206];
207
208pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
211 let elapsed = chrono::Utc::now()
212 .signed_duration_since(run.updated_at)
213 .num_minutes();
214 elapsed > 30
215}
216
217fn is_shell_tool_name(name: &str) -> bool {
218 matches!(name, "ctx_shell" | "ctx_execute")
219}
220
221fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
222 let trimmed = cmd.trim();
223 let parts: Vec<&str> = trimmed.split_whitespace().collect();
224 if parts.len() < 2 {
225 return None;
226 }
227 let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
228 match bin {
229 "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
230 let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
231 Some(file_arg.to_string())
232 }
233 _ => None,
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn project_markers_detected() {
243 let tmp = tempfile::tempdir().unwrap();
244 let root = tmp.path().join("myproject");
245 std::fs::create_dir_all(&root).unwrap();
246 assert!(!has_project_marker(&root));
247
248 std::fs::create_dir(root.join(".git")).unwrap();
249 assert!(has_project_marker(&root));
250 }
251
252 #[test]
253 fn home_dir_detected_as_agent_dir() {
254 if let Some(home) = dirs::home_dir() {
255 assert!(is_home_or_agent_dir(&home));
256 }
257 }
258
259 #[test]
260 fn agent_dirs_detected() {
261 let claude = std::path::PathBuf::from("/home/user/.claude");
262 assert!(is_home_or_agent_dir(&claude));
263 let codex = std::path::PathBuf::from("/home/user/.codex");
264 assert!(is_home_or_agent_dir(&codex));
265 let project = std::path::PathBuf::from("/home/user/projects/myapp");
266 assert!(!is_home_or_agent_dir(&project));
267 }
268
269 #[test]
270 fn test_unified_tool_count() {
271 let tools = crate::tool_defs::unified_tool_defs();
272 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
273 }
274
275 #[test]
276 fn test_granular_tool_count() {
277 let tools = crate::tool_defs::granular_tool_defs();
278 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
279 }
280
281 #[test]
282 fn test_registry_tool_count_ssot() {
283 let registry = crate::server::registry::build_registry();
284 assert_eq!(
285 registry.len(),
286 81,
287 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
288 );
289 }
290
291 #[test]
292 fn production_server_always_has_registry() {
293 let server = crate::tools::create_server();
299 assert!(
300 server.registry.is_some(),
301 "production server must carry a tool registry"
302 );
303 }
304
305 #[test]
306 fn disabled_tools_filters_list() {
307 let all = crate::tool_defs::granular_tool_defs();
308 let total = all.len();
309 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
310 let filtered: Vec<_> = all
311 .into_iter()
312 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
313 .collect();
314 assert_eq!(filtered.len(), total - 2);
315 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
316 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
317 }
318
319 #[test]
320 fn empty_disabled_tools_returns_all() {
321 let all = crate::tool_defs::granular_tool_defs();
322 let total = all.len();
323 let disabled: Vec<String> = vec![];
324 let filtered: Vec<_> = all
325 .into_iter()
326 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
327 .collect();
328 assert_eq!(filtered.len(), total);
329 }
330
331 #[test]
332 fn misspelled_disabled_tool_is_silently_ignored() {
333 let all = crate::tool_defs::granular_tool_defs();
334 let total = all.len();
335 let disabled = ["ctx_nonexistent_tool".to_string()];
336 let filtered: Vec<_> = all
337 .into_iter()
338 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
339 .collect();
340 assert_eq!(filtered.len(), total);
341 }
342
343 #[test]
344 fn detect_multi_root_workspace_with_child_projects() {
345 let _guard = crate::core::data_dir::test_env_lock();
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 let proj_b = workspace.join("project-b");
356 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
357 std::fs::create_dir_all(&proj_b).unwrap();
358 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
359
360 let result = detect_multi_root_workspace(&workspace);
361 assert!(
362 result.is_some(),
363 "should detect workspace with 2 child projects"
364 );
365
366 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
367 }
368
369 #[test]
370 fn detect_multi_root_workspace_returns_none_for_single_project() {
371 let tmp = tempfile::tempdir().unwrap();
372 let workspace = tmp.path().join("workspace");
373 std::fs::create_dir_all(&workspace).unwrap();
374
375 let proj_a = workspace.join("project-a");
376 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
377
378 let result = detect_multi_root_workspace(&workspace);
379 assert!(
380 result.is_none(),
381 "should not detect workspace with only 1 child project"
382 );
383 }
384
385 #[test]
386 fn is_broad_or_unsafe_root_rejects_home() {
387 if let Some(home) = dirs::home_dir() {
388 assert!(is_broad_or_unsafe_root(&home));
389 }
390 }
391
392 #[test]
393 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
394 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
395 }
396
397 #[test]
398 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
399 assert!(is_broad_or_unsafe_root(std::path::Path::new(
400 "/home/user/.claude"
401 )));
402 assert!(is_broad_or_unsafe_root(std::path::Path::new(
403 "/home/user/.codex"
404 )));
405 }
406
407 #[test]
408 fn is_broad_or_unsafe_root_allows_project_subdir() {
409 let tmp = tempfile::tempdir().unwrap();
410 let subdir = tmp.path().join("my-project");
411 std::fs::create_dir_all(&subdir).unwrap();
412 assert!(!is_broad_or_unsafe_root(&subdir));
413 }
414
415 #[test]
416 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
417 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
418 "/tmp/leanctx-test"
419 )));
420 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
421 "/tmp/my-project"
422 )));
423 }
424
425 #[test]
426 fn is_broad_or_unsafe_root_allows_home_subdirs() {
427 if let Some(home) = dirs::home_dir() {
428 let subdir = home.join("projects").join("my-app");
429 assert!(!is_broad_or_unsafe_root(&subdir));
430 }
431 }
432
433 #[test]
434 fn derive_project_root_falls_back_to_bare_cwd() {
435 let tmp = tempfile::tempdir().unwrap();
436 let bare = tmp.path().join("bare-dir");
437 std::fs::create_dir_all(&bare).unwrap();
438
439 let original = std::env::current_dir().unwrap();
440 std::env::set_current_dir(&bare).unwrap();
441 let result = derive_project_root_from_cwd();
442 std::env::set_current_dir(original).unwrap();
443
444 assert!(result.is_some(), "bare dir should produce a project root");
445 let root = result.unwrap();
446 assert!(
447 root.contains("bare-dir"),
448 "fallback should use the bare dir path"
449 );
450 }
451}