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