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