1pub mod bypass_hint;
2pub mod compaction_sync;
3pub mod context_gate;
4mod dispatch;
5pub mod dynamic_tools;
6pub mod elicitation;
7pub(crate) mod execute;
8pub mod helpers;
9pub mod notifications;
10pub mod prompts;
11pub mod reference_store;
12pub mod registry;
13pub mod resources;
14pub mod role_guard;
15pub mod tool_trait;
16
17use rmcp::handler::server::ServerHandler;
18use rmcp::model::{
19 CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
20 InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
21};
22use rmcp::service::{RequestContext, RoleServer};
23use rmcp::ErrorData;
24
25use crate::tools::{CrpMode, LeanCtxServer};
26
27impl ServerHandler for LeanCtxServer {
28 fn get_info(&self) -> ServerInfo {
29 let capabilities = ServerCapabilities::builder()
30 .enable_tools()
31 .enable_resources()
32 .enable_resources_subscribe()
33 .enable_prompts()
34 .build();
35
36 let config = crate::core::config::Config::load();
37 let level = crate::core::config::CompressionLevel::effective(&config);
38 let _ = crate::core::terse::rules_inject::inject(&level);
39
40 let instructions = crate::instructions::build_instructions(CrpMode::effective());
41
42 InitializeResult::new(capabilities)
43 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
44 .with_instructions(instructions)
45 }
46
47 async fn initialize(
48 &self,
49 request: InitializeRequestParams,
50 context: RequestContext<RoleServer>,
51 ) -> Result<InitializeResult, ErrorData> {
52 let name = request.client_info.name.clone();
53 tracing::info!("MCP client connected: {:?}", name);
54 *self.client_name.write().await = name.clone();
55 *self.peer.write().await = Some(context.peer.clone());
56
57 if self.session_mode != crate::tools::SessionMode::Shared {
58 crate::core::budget_tracker::BudgetTracker::global().reset();
59 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
60 let radar = data_dir.join("context_radar.jsonl");
61 if radar.exists() {
62 let prev = data_dir.join("context_radar.prev.jsonl");
63 let _ = std::fs::rename(&radar, &prev);
64 }
65 }
66 }
67
68 let derived_root = derive_project_root_from_cwd();
69 let cwd_str = std::env::current_dir()
70 .ok()
71 .map(|p| p.to_string_lossy().to_string())
72 .unwrap_or_default();
73 {
74 let mut session = self.session.write().await;
75 if !cwd_str.is_empty() {
76 session.shell_cwd = Some(cwd_str.clone());
77 }
78 if let Some(ref root) = derived_root {
79 session.project_root = Some(root.clone());
80 tracing::info!("Project root set to: {root}");
81 } else if let Some(ref root) = session.project_root {
82 let root_path = std::path::Path::new(root);
83 let root_has_marker = has_project_marker(root_path);
84 let root_str = root_path.to_string_lossy();
85 let root_suspicious = root_str.contains("/.claude")
86 || root_str.contains("/.codex")
87 || root_str.contains("/var/folders/")
88 || root_str.contains("/tmp/")
89 || root_str.contains("\\.claude")
90 || root_str.contains("\\.codex")
91 || root_str.contains("\\AppData\\Local\\Temp")
92 || root_str.contains("\\Temp\\");
93 if root_suspicious && !root_has_marker {
94 session.project_root = None;
95 }
96 }
97 if self.session_mode == crate::tools::SessionMode::Shared {
98 if let Some(ref root) = session.project_root {
99 if let Some(ref rt) = self.context_os {
100 rt.shared_sessions.persist_best_effort(
101 root,
102 &self.workspace_id,
103 &self.channel_id,
104 &session,
105 );
106 rt.metrics.record_session_persisted();
107 }
108 }
109 } else {
110 let _ = session.save();
111 }
112 }
113
114 let agent_name = name.clone();
115 let agent_root = derived_root.clone().unwrap_or_default();
116 let agent_id_handle = self.agent_id.clone();
117 tokio::task::spawn_blocking(move || {
118 if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
119 return;
120 }
121
122 let maintenance = crate::core::startup_guard::try_acquire_lock(
126 "startup-maintenance",
127 std::time::Duration::from_secs(2),
128 std::time::Duration::from_mins(2),
129 );
130 if maintenance.is_some() {
131 if let Some(home) = dirs::home_dir() {
132 let _ = crate::rules_inject::inject_all_rules(&home);
133 }
134 crate::hooks::refresh_installed_hooks();
135 crate::core::version_check::check_background();
136 }
137 drop(maintenance);
138
139 if !agent_root.is_empty() {
140 let heuristic_role = match agent_name.to_lowercase().as_str() {
141 n if n.contains("cursor") => Some("coder"),
142 n if n.contains("claude") => Some("coder"),
143 n if n.contains("codex") => Some("coder"),
144 n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
145 n if n.contains("review") => Some("reviewer"),
146 n if n.contains("test") => Some("debugger"),
147 _ => None,
148 };
149 let env_role = std::env::var("LEAN_CTX_ROLE")
150 .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
151 .ok();
152 let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
153
154 let _ = crate::core::roles::set_active_role(effective_role);
155
156 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
157 registry.cleanup_stale(24);
158 let id = registry.register("mcp", Some(effective_role), &agent_root);
159 let _ = registry.save();
160 if let Ok(mut guard) = agent_id_handle.try_write() {
161 *guard = Some(id);
162 }
163 }
164 });
165
166 let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
167 tracing::info!("Client capabilities: {}", client_caps.format_summary());
168
169 if client_caps.dynamic_tools {
170 if let Ok(mut dt) = dynamic_tools::global().lock() {
171 dt.set_supports_list_changed(true);
172 }
173 }
174 if let Some(max) = client_caps.max_tools {
175 if let Ok(mut dt) = dynamic_tools::global().lock() {
176 dt.set_supports_list_changed(true);
177 if max < 100 {
178 dt.unload_category(dynamic_tools::ToolCategory::Debug);
179 dt.unload_category(dynamic_tools::ToolCategory::Memory);
180 }
181 }
182 }
183
184 crate::core::client_capabilities::set_detected(&client_caps);
185
186 let instructions =
187 crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
188
189 let capabilities = match (client_caps.resources, client_caps.prompts) {
190 (true, true) => ServerCapabilities::builder()
191 .enable_tools()
192 .enable_resources()
193 .enable_resources_subscribe()
194 .enable_prompts()
195 .build(),
196 (true, false) => ServerCapabilities::builder()
197 .enable_tools()
198 .enable_resources()
199 .enable_resources_subscribe()
200 .build(),
201 (false, true) => ServerCapabilities::builder()
202 .enable_tools()
203 .enable_prompts()
204 .build(),
205 (false, false) => ServerCapabilities::builder().enable_tools().build(),
206 };
207
208 Ok(InitializeResult::new(capabilities)
209 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
210 .with_instructions(instructions))
211 }
212
213 async fn list_tools(
214 &self,
215 _request: Option<PaginatedRequestParams>,
216 _context: RequestContext<RoleServer>,
217 ) -> Result<ListToolsResult, ErrorData> {
218 let all_tools = if crate::tool_defs::is_full_mode() {
219 if let Some(ref reg) = self.registry {
220 reg.tool_defs()
221 } else {
222 crate::tool_defs::granular_tool_defs()
223 }
224 } else if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
225 crate::tool_defs::unified_tool_defs()
226 } else if let Some(ref reg) = self.registry {
227 let core_names = crate::tool_defs::core_tool_names();
228 reg.tool_defs()
229 .into_iter()
230 .filter(|t| core_names.contains(&t.name.as_ref()))
231 .collect()
232 } else {
233 crate::tool_defs::lazy_tool_defs()
234 };
235
236 let disabled = crate::core::config::Config::load().disabled_tools_effective();
237 let client = self.client_name.read().await.clone();
238 let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
239
240 let tools: Vec<_> = all_tools
241 .into_iter()
242 .filter(|t| {
243 let name = t.name.as_ref();
244 if !disabled.is_empty() && disabled.iter().any(|d| d.as_str() == name) {
245 return false;
246 }
247 if is_zed && name == "ctx_edit" {
248 return false;
249 }
250 true
251 })
252 .collect();
253
254 let tools = {
255 let dyn_state = dynamic_tools::global().lock().unwrap();
256 if dyn_state.supports_list_changed() {
257 tools
258 .into_iter()
259 .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
260 .collect()
261 } else {
262 tools
263 }
264 };
265
266 let tools = {
267 let active = self.workflow.read().await.clone();
268 if let Some(run) = active {
269 if run.current == "done" || is_workflow_stale(&run) {
270 let mut wf = self.workflow.write().await;
271 *wf = None;
272 let _ = crate::core::workflow::clear_active();
273 } else if let Some(state) = run.spec.state(&run.current) {
274 if let Some(allowed) = &state.allowed_tools {
275 let mut allow: std::collections::HashSet<&str> =
276 allowed.iter().map(std::string::String::as_str).collect();
277 for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
278 allow.insert(passthrough);
279 }
280 return Ok(ListToolsResult {
281 tools: tools
282 .into_iter()
283 .filter(|t| allow.contains(t.name.as_ref()))
284 .collect(),
285 ..Default::default()
286 });
287 }
288 }
289 }
290 tools
291 };
292
293 let tools = {
294 let cfg = crate::core::config::Config::load();
295 let level = crate::core::config::CompressionLevel::effective(&cfg);
296 let mode =
297 crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
298 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
299 tools
300 } else {
301 tools
302 .into_iter()
303 .map(|mut t| {
304 let compressed = crate::core::terse::mcp_compress::compress_description(
305 t.name.as_ref(),
306 t.description.as_deref().unwrap_or(""),
307 mode,
308 );
309 t.description = Some(compressed.into());
310 t
311 })
312 .collect()
313 }
314 };
315
316 Ok(ListToolsResult {
317 tools,
318 ..Default::default()
319 })
320 }
321
322 async fn list_prompts(
323 &self,
324 _request: Option<PaginatedRequestParams>,
325 _context: RequestContext<RoleServer>,
326 ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
327 Ok(rmcp::model::ListPromptsResult::with_all_items(
328 prompts::list_prompts(),
329 ))
330 }
331
332 async fn get_prompt(
333 &self,
334 request: rmcp::model::GetPromptRequestParams,
335 _context: RequestContext<RoleServer>,
336 ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
337 let ledger = self.ledger.read().await;
338 match prompts::get_prompt(&request, &ledger) {
339 Some(result) => Ok(result),
340 None => Err(ErrorData::invalid_params(
341 format!("Unknown prompt: {}", request.name),
342 None,
343 )),
344 }
345 }
346
347 async fn list_resources(
348 &self,
349 _request: Option<PaginatedRequestParams>,
350 _context: RequestContext<RoleServer>,
351 ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
352 Ok(rmcp::model::ListResourcesResult::with_all_items(
353 resources::list_resources(),
354 ))
355 }
356
357 async fn read_resource(
358 &self,
359 request: rmcp::model::ReadResourceRequestParams,
360 _context: RequestContext<RoleServer>,
361 ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
362 let ledger = self.ledger.read().await;
363 match resources::read_resource(&request.uri, &ledger) {
364 Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
365 None => Err(rmcp::ErrorData::resource_not_found(
366 format!("Unknown resource: {}", request.uri),
367 None,
368 )),
369 }
370 }
371
372 async fn call_tool(
373 &self,
374 request: CallToolRequestParams,
375 _context: RequestContext<RoleServer>,
376 ) -> Result<CallToolResult, ErrorData> {
377 self.check_idle_expiry().await;
378 elicitation::increment_call();
379
380 let original_name = request.name.as_ref().to_string();
381 let (resolved_name, resolved_args) = if original_name == "ctx" {
382 let sub = request
383 .arguments
384 .as_ref()
385 .and_then(|a| a.get("tool"))
386 .and_then(|v| v.as_str())
387 .map(std::string::ToString::to_string)
388 .ok_or_else(|| {
389 ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
390 })?;
391 let tool_name = if sub.starts_with("ctx_") {
392 sub
393 } else {
394 format!("ctx_{sub}")
395 };
396 let mut args = request.arguments.unwrap_or_default();
397 args.remove("tool");
398 (tool_name, Some(args))
399 } else {
400 (original_name, request.arguments)
401 };
402 let name = resolved_name.as_str();
403 let args = resolved_args.as_ref();
404
405 let role_check = role_guard::check_tool_access(name);
406 if let Some(denied) = role_guard::into_call_tool_result(&role_check) {
407 tracing::warn!(
408 tool = name,
409 role = %role_check.role_name,
410 "Tool blocked by role policy"
411 );
412 return Ok(denied);
413 }
414
415 if name != "ctx_workflow" {
416 let active = self.workflow.read().await.clone();
417 if let Some(run) = active {
418 if run.current == "done" || is_workflow_stale(&run) {
419 let mut wf = self.workflow.write().await;
420 *wf = None;
421 let _ = crate::core::workflow::clear_active();
422 } else if !WORKFLOW_PASSTHROUGH_TOOLS.contains(&name) {
423 if let Some(state) = run.spec.state(&run.current) {
424 if let Some(allowed) = &state.allowed_tools {
425 let allowed_ok = allowed.iter().any(|t| t == name);
426 if !allowed_ok {
427 let mut shown = allowed.clone();
428 shown.sort();
429 shown.truncate(30);
430 return Ok(CallToolResult::success(vec![Content::text(format!(
431 "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed: {}. Use ctx_workflow(action=\"stop\") to exit.",
432 run.spec.name,
433 run.current,
434 shown.join(", ")
435 ))]));
436 }
437 }
438 }
439 }
440 }
441 }
442
443 let auto_context = {
444 let task = {
445 let session = self.session.read().await;
446 session.task.as_ref().map(|t| t.description.clone())
447 };
448 let project_root = {
449 let session = self.session.read().await;
450 session.project_root.clone()
451 };
452 let mut cache = self.cache.write().await;
453 crate::tools::autonomy::session_lifecycle_pre_hook(
454 &self.autonomy,
455 name,
456 &mut cache,
457 task.as_deref(),
458 project_root.as_deref(),
459 CrpMode::effective(),
460 )
461 };
462
463 let throttle_result = {
464 let fp = args
465 .map(|a| {
466 crate::core::loop_detection::LoopDetector::fingerprint(
467 &serde_json::Value::Object(a.clone()),
468 )
469 })
470 .unwrap_or_default();
471 let mut detector = self.loop_detector.write().await;
472
473 let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
474 let is_search_shell = name == "ctx_shell" && {
475 let cmd = args
476 .as_ref()
477 .and_then(|a| a.get("command"))
478 .and_then(|v| v.as_str())
479 .unwrap_or("");
480 crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
481 };
482
483 if is_search || is_search_shell {
484 let search_pattern = args.and_then(|a| {
485 a.get("pattern")
486 .or_else(|| a.get("query"))
487 .and_then(|v| v.as_str())
488 });
489 let shell_pattern = if is_search_shell {
490 args.and_then(|a| a.get("command"))
491 .and_then(|v| v.as_str())
492 .and_then(helpers::extract_search_pattern_from_command)
493 } else {
494 None
495 };
496 let pat = search_pattern.or(shell_pattern.as_deref());
497 detector.record_search(name, &fp, pat)
498 } else {
499 detector.record_call(name, &fp)
500 }
501 };
502
503 if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
504 let msg = throttle_result.message.unwrap_or_default();
505 return Ok(CallToolResult::success(vec![Content::text(msg)]));
506 }
507
508 let throttle_warning =
509 if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
510 throttle_result.message.clone()
511 } else {
512 None
513 };
514
515 let config = crate::core::config::Config::load();
516 let minimal = config.minimal_overhead_effective();
517
518 {
519 use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
520 let snap = BudgetTracker::global().check();
521 if *snap.worst_level() == BudgetLevel::Exhausted
522 && name != "ctx_session"
523 && name != "ctx_cost"
524 && name != "ctx_metrics"
525 {
526 for (dim, lvl, used, limit) in [
527 (
528 "tokens",
529 &snap.tokens.level,
530 format!("{}", snap.tokens.used),
531 format!("{}", snap.tokens.limit),
532 ),
533 (
534 "shell",
535 &snap.shell.level,
536 format!("{}", snap.shell.used),
537 format!("{}", snap.shell.limit),
538 ),
539 (
540 "cost",
541 &snap.cost.level,
542 format!("${:.2}", snap.cost.used_usd),
543 format!("${:.2}", snap.cost.limit_usd),
544 ),
545 ] {
546 if *lvl == BudgetLevel::Exhausted {
547 crate::core::events::emit_budget_exhausted(&snap.role, dim, &used, &limit);
548 }
549 }
550 let msg = format!(
551 "[BUDGET EXHAUSTED] {}\n\
552 Use `ctx_session action=role` to check/switch roles, \
553 or `ctx_session action=reset` to start fresh.",
554 snap.format_compact()
555 );
556 tracing::warn!(tool = name, "{msg}");
557 return Ok(CallToolResult::success(vec![Content::text(msg)]));
558 }
559 }
560
561 if is_shell_tool_name(name) {
562 crate::core::budget_tracker::BudgetTracker::global().record_shell();
563 }
564
565 let tool_start = std::time::Instant::now();
566 let (mut result_text, tool_saved_tokens) = {
567 use futures::FutureExt;
568 use std::panic::AssertUnwindSafe;
569 match AssertUnwindSafe(self.dispatch_tool(name, args, minimal))
570 .catch_unwind()
571 .await
572 {
573 Ok(Ok(pair)) => pair,
574 Ok(Err(e)) => return Err(e),
575 Err(panic_payload) => {
576 let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
577 (*s).to_string()
578 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
579 s.clone()
580 } else {
581 "unknown".to_string()
582 };
583 tracing::error!(tool = name, "Tool panicked: {detail}");
584 (format!("ERROR: lean-ctx internal error in tool '{name}'.\n\
585 The MCP server is still running. Please retry or use a different approach."), 0)
586 }
587 }
588 };
589
590 let is_raw_shell = name == "ctx_shell" && {
591 let arg_raw = helpers::get_bool(args, "raw").unwrap_or(false);
592 let arg_bypass = helpers::get_bool(args, "bypass").unwrap_or(false);
593 arg_raw
594 || arg_bypass
595 || std::env::var("LEAN_CTX_DISABLED").is_ok()
596 || std::env::var("LEAN_CTX_RAW").is_ok()
597 };
598
599 let pre_terse_len = result_text.len();
600 let output_tokens = {
601 let tokens = crate::core::tokens::count_tokens(&result_text) as u64;
602 crate::core::budget_tracker::BudgetTracker::global().record_tokens(tokens);
603 tokens
604 };
605
606 crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
607
608 if let Some(ref ir) = self.context_ir {
610 let tool_duration = tool_start.elapsed();
611 let source_kind = match name {
612 n if n.contains("read") || n.contains("multi_read") || n.contains("smart_read") => {
613 crate::core::context_ir::ContextIrSourceKindV1::Read
614 }
615 "ctx_shell" => crate::core::context_ir::ContextIrSourceKindV1::Shell,
616 "ctx_search" | "ctx_semantic_search" => {
617 crate::core::context_ir::ContextIrSourceKindV1::Search
618 }
619 "ctx_provider" => crate::core::context_ir::ContextIrSourceKindV1::Provider,
620 _ => crate::core::context_ir::ContextIrSourceKindV1::Other,
621 };
622 let ir_path = helpers::get_str(args, "path");
623 let ir_command = helpers::get_str(args, "command");
624 let ir_mode = helpers::get_str(args, "mode");
625 let excerpt = if result_text.len() > 200 {
626 &result_text[..200]
627 } else {
628 &result_text
629 };
630 let input = crate::core::context_ir::RecordIrInput {
631 kind: source_kind,
632 tool: name,
633 client_name: None,
634 agent_id: None,
635 path: ir_path.as_deref(),
636 command: ir_command.as_deref(),
637 pattern: ir_mode.as_deref(),
638 input_tokens: pre_terse_len / 4,
639 output_tokens: output_tokens as usize,
640 duration: tool_duration,
641 content_excerpt: excerpt,
642 };
643 ir.write().await.record(input);
644 }
645
646 {
648 let mut detector = self.loop_detector.write().await;
649 if name == "ctx_read" {
650 let path = helpers::get_str(args, "path").unwrap_or_default();
651 let mode = helpers::get_str(args, "mode").unwrap_or_else(|| "auto".into());
652 let fresh = helpers::get_bool(args, "fresh").unwrap_or(false);
653 detector.record_read_for_correction(&path, &mode, fresh);
654 } else if name == "ctx_shell" {
655 let cmd = helpers::get_str(args, "command").unwrap_or_default();
656 detector.record_shell_for_correction(&cmd);
657 }
658 let correction_count = detector.correction_count();
659 if correction_count > 0 {
660 crate::core::anomaly::record_metric(
661 "correction_loop_rate",
662 f64::from(correction_count),
663 );
664 }
665 use crate::core::config::CompressionLevel;
667 if correction_count >= 5 {
668 CompressionLevel::set_session_degrade(&CompressionLevel::Off);
669 } else if correction_count >= 3 {
670 CompressionLevel::set_session_degrade(&CompressionLevel::Lite);
671 } else if correction_count == 0 {
672 CompressionLevel::clear_session_degrade();
673 }
674 detector.prune_corrections();
675 }
676
677 crate::core::anomaly::save_debounced();
679
680 let budget_warning = {
681 use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
682 let snap = BudgetTracker::global().check();
683 if *snap.worst_level() == BudgetLevel::Warning {
684 for (dim, lvl, used, limit, pct) in [
685 (
686 "tokens",
687 &snap.tokens.level,
688 format!("{}", snap.tokens.used),
689 format!("{}", snap.tokens.limit),
690 snap.tokens.percent,
691 ),
692 (
693 "shell",
694 &snap.shell.level,
695 format!("{}", snap.shell.used),
696 format!("{}", snap.shell.limit),
697 snap.shell.percent,
698 ),
699 (
700 "cost",
701 &snap.cost.level,
702 format!("${:.2}", snap.cost.used_usd),
703 format!("${:.2}", snap.cost.limit_usd),
704 snap.cost.percent,
705 ),
706 ] {
707 if *lvl == BudgetLevel::Warning {
708 crate::core::events::emit_budget_warning(
709 &snap.role, dim, &used, &limit, pct,
710 );
711 }
712 }
713 if crate::core::protocol::meta_visible() {
714 Some(format!("[BUDGET WARNING] {}", snap.format_compact()))
715 } else {
716 None
717 }
718 } else {
719 None
720 }
721 };
722
723 let archive_hint = if minimal || is_raw_shell {
724 None
725 } else {
726 use crate::core::archive;
727 let archivable = matches!(
728 name,
729 "ctx_shell"
730 | "ctx_read"
731 | "ctx_multi_read"
732 | "ctx_smart_read"
733 | "ctx_execute"
734 | "ctx_search"
735 | "ctx_tree"
736 );
737 if archivable && archive::should_archive(&result_text) {
738 let cmd = helpers::get_str(args, "command")
739 .or_else(|| helpers::get_str(args, "path"))
740 .unwrap_or_default();
741 let session_id = self.session.read().await.id.clone();
742 let to_store = crate::core::redaction::redact_text_if_enabled(&result_text);
743 let tokens = crate::core::tokens::count_tokens(&to_store);
744 archive::store(name, &cmd, &to_store, Some(&session_id))
745 .map(|id| archive::format_hint(&id, to_store.len(), tokens))
746 } else {
747 None
748 }
749 };
750
751 let pre_compression = result_text.clone();
752 let skip_terse = is_raw_shell
753 || tool_saved_tokens > 0
754 || (name == "ctx_shell"
755 && helpers::get_str(args, "command")
756 .is_some_and(|c| crate::shell::compress::has_structural_output(&c)));
757 let compression = crate::core::config::CompressionLevel::effective(&config);
758 if compression.is_active() && !skip_terse {
759 let terse_result =
760 crate::core::terse::pipeline::compress(&result_text, &compression, None);
761 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
762 result_text = terse_result.output;
763 }
764 }
765
766 let profile_hints = crate::core::profiles::active_profile().output_hints;
767
768 if !is_raw_shell && profile_hints.verify_footer() {
769 let verify_cfg = crate::core::profiles::active_profile().verification;
770 let vr = crate::core::output_verification::verify_output(
771 &pre_compression,
772 &result_text,
773 &verify_cfg,
774 );
775 if !vr.warnings.is_empty() {
776 let msg = format!("[VERIFY] {}", vr.format_compact());
777 result_text = format!("{result_text}\n\n{msg}");
778 }
779 }
780
781 if profile_hints.archive_hint() {
782 if let Some(hint) = archive_hint {
783 result_text = format!("{result_text}\n{hint}");
784 }
785 }
786
787 if !is_raw_shell {
788 if let Some(ctx) = auto_context {
789 let ctx_tokens = crate::core::tokens::count_tokens(&ctx);
790 if ctx_tokens <= 400 {
791 result_text = format!("{ctx}\n\n{result_text}");
792 }
793 }
794 }
795
796 if let Some(warning) = throttle_warning {
797 result_text = format!("{result_text}\n\n{warning}");
798 }
799
800 if let Some(bw) = budget_warning {
801 result_text = format!("{result_text}\n\n{bw}");
802 }
803
804 if !self
805 .rules_stale_checked
806 .swap(true, std::sync::atomic::Ordering::Relaxed)
807 {
808 let client = self.client_name.read().await.clone();
809 if !client.is_empty() {
810 if let Some(stale_msg) = crate::rules_inject::check_rules_freshness(&client) {
811 result_text = format!("{result_text}\n\n{stale_msg}");
812 }
813 }
814 }
815
816 {
817 let _ = crate::core::slo::evaluate();
819 }
820
821 if name == "ctx_read" {
822 if minimal {
823 let mut cache = self.cache.write().await;
824 crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
825 } else {
826 let read_path = self
827 .resolve_path_or_passthrough(
828 &helpers::get_str(args, "path").unwrap_or_default(),
829 )
830 .await;
831 let project_root = {
832 let session = self.session.read().await;
833 session.project_root.clone()
834 };
835 let mut cache = self.cache.write().await;
836 let enrich = crate::tools::autonomy::enrich_after_read(
837 &self.autonomy,
838 &mut cache,
839 &read_path,
840 project_root.as_deref(),
841 None,
842 crate::tools::CrpMode::effective(),
843 false,
844 );
845 if profile_hints.related_hint() {
846 if let Some(hint) = enrich.related_hint {
847 result_text = format!("{result_text}\n{hint}");
848 }
849 }
850 crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
851
852 {
853 let active_task = {
854 let session = self.session.read().await;
855 session.task.as_ref().map(|t| t.description.clone())
856 };
857 let mut ledger = self.ledger.write().await;
858 let overlay = crate::core::context_overlay::OverlayStore::load_project(
859 &std::path::PathBuf::from(project_root.as_deref().unwrap_or(".")),
860 );
861 let mode_used =
862 helpers::get_str(args, "mode").unwrap_or_else(|| "auto".to_string());
863 let sent_tokens_final = crate::core::tokens::count_tokens(&result_text);
864 let gate_result = context_gate::post_dispatch_record_with_task(
865 &read_path,
866 &mode_used,
867 output_tokens as usize,
868 sent_tokens_final,
869 &mut ledger,
870 &overlay,
871 active_task.as_deref(),
872 );
873 if let Some(hint) = gate_result.eviction_hint {
874 result_text = format!("{result_text}\n{hint}");
875 }
876 if profile_hints.elicitation_hint() {
877 if let Some(hint) = gate_result.elicitation_hint {
878 result_text = format!("{result_text}\n{hint}");
879 }
880 }
881 if gate_result.resource_changed {
882 if let Some(peer) = self.peer.read().await.as_ref() {
883 notifications::send_resource_updated(
884 peer,
885 notifications::RESOURCE_URI_SUMMARY,
886 )
887 .await;
888 }
889 }
890 }
891 }
892 }
893
894 if !minimal && !is_raw_shell && name == "ctx_shell" {
895 let cmd = helpers::get_str(args, "command").unwrap_or_default();
896
897 if let Some(file_path) = extract_file_read_from_shell(&cmd) {
898 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
899 bt.next_seq();
900 bt.record_shell_file_access(&file_path);
901 }
902 }
903
904 if profile_hints.efficiency_hint() {
905 let calls = self.tool_calls.read().await;
906 let last_original = calls.last().map_or(0, |c| c.original_tokens);
907 drop(calls);
908 let pre_hint_tokens = crate::core::tokens::count_tokens(&result_text);
909 if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
910 &self.autonomy,
911 &cmd,
912 last_original,
913 pre_hint_tokens,
914 ) {
915 result_text = format!("{result_text}\n{hint}");
916 }
917 }
918 }
919
920 if !minimal && !is_raw_shell {
921 bypass_hint::record_lctx_call();
922 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
923 if let Some(hint) = bypass_hint::check(&data_dir) {
924 result_text = format!("{result_text}\n{hint}");
925 }
926 }
927 }
928
929 #[allow(clippy::cast_possible_truncation)]
930 let output_token_count = if result_text.len() == pre_terse_len {
931 output_tokens as usize
932 } else {
933 crate::core::tokens::count_tokens(&result_text)
934 };
935 let action = helpers::get_str(args, "action");
936
937 const K_STALENESS_BOUND: i64 = 10;
939 if self.session_mode == crate::tools::SessionMode::Shared {
940 if let Some(ref rt) = self.context_os {
941 let latest = rt.bus.latest_id(&self.workspace_id, &self.channel_id);
942 let cursor = self
943 .last_seen_event_id
944 .load(std::sync::atomic::Ordering::Relaxed);
945 if cursor > 0 && latest - cursor > K_STALENESS_BOUND {
946 let gap = latest - cursor;
947 result_text = format!(
948 "[CONTEXT STALE] {gap} events happened since your last read. \
949 Use ctx_session(action=\"status\") to sync.\n\n{result_text}"
950 );
951 }
952 self.last_seen_event_id
953 .store(latest, std::sync::atomic::Ordering::Relaxed);
954 }
955 }
956
957 {
958 let input = helpers::canonical_args_string(args);
959 let input_md5 = helpers::hash_fast(&input);
960 let output_md5 = helpers::hash_fast(&result_text);
961 let agent_id = self.agent_id.read().await.clone();
962 let client_name = self.client_name.read().await.clone();
963 let mut explicit_intent: Option<(
964 crate::core::intent_protocol::IntentRecord,
965 Option<String>,
966 String,
967 )> = None;
968
969 let pending_session_save = {
970 let empty_args = serde_json::Map::new();
971 let args_map = args.unwrap_or(&empty_args);
972 let mut session = self.session.write().await;
973 session.record_tool_receipt(
974 name,
975 action.as_deref(),
976 &input_md5,
977 &output_md5,
978 agent_id.as_deref(),
979 Some(&client_name),
980 );
981
982 if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
983 name,
984 action.as_deref(),
985 args_map,
986 session.project_root.as_deref(),
987 ) {
988 let is_explicit =
989 intent.source == crate::core::intent_protocol::IntentSource::Explicit;
990 let root = session.project_root.clone();
991 let sid = session.id.clone();
992 session.record_intent(intent.clone());
993 if is_explicit {
994 explicit_intent = Some((intent, root, sid));
995 }
996 }
997 if session.should_save() {
998 session.prepare_save().ok()
999 } else {
1000 None
1001 }
1002 };
1003
1004 if let Some(prepared) = pending_session_save {
1005 let ir_clone = self.context_ir.clone();
1006 tokio::task::spawn_blocking(move || {
1007 let _ = prepared.write_to_disk();
1008 if let Some(ir) = ir_clone {
1009 if let Ok(ir_guard) = ir.try_read() {
1010 ir_guard.save();
1011 }
1012 }
1013 });
1014 }
1015
1016 if let Some((intent, root, session_id)) = explicit_intent {
1017 let _ = crate::core::intent_protocol::apply_side_effects(
1018 &intent,
1019 root.as_deref(),
1020 &session_id,
1021 );
1022 }
1023
1024 if self.autonomy.is_enabled() {
1025 let (calls, project_root) = {
1026 let session = self.session.read().await;
1027 (session.stats.total_tool_calls, session.project_root.clone())
1028 };
1029
1030 if let Some(root) = project_root {
1031 if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
1032 let root_clone = root.clone();
1033 tokio::task::spawn_blocking(move || {
1034 let _ = crate::core::consolidation_engine::consolidate_latest(
1035 &root_clone,
1036 crate::core::consolidation_engine::ConsolidationBudgets::default(),
1037 );
1038 });
1039 }
1040 }
1041 }
1042
1043 let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
1044 let input_token_count = crate::core::tokens::count_tokens(&input) as u64;
1045 let output_token_count_u64 = output_token_count as u64;
1046 let name_owned = name.to_string();
1047 tokio::task::spawn_blocking(move || {
1048 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
1049 let quote = pricing.quote_from_env_or_agent_type(&client_name);
1050 let cost_usd =
1051 quote
1052 .cost
1053 .estimate_usd(input_token_count, output_token_count_u64, 0, 0);
1054 crate::core::budget_tracker::BudgetTracker::global().record_cost_usd(cost_usd);
1055
1056 let mut store = crate::core::a2a::cost_attribution::CostStore::load();
1057 store.record_tool_call(
1058 &agent_key,
1059 &client_name,
1060 &name_owned,
1061 input_token_count,
1062 output_token_count_u64,
1063 0,
1064 );
1065 let _ = store.save();
1066 });
1067 }
1068
1069 if self.session_mode == crate::tools::SessionMode::Shared
1071 && name == "ctx_knowledge"
1072 && action.as_deref() == Some("remember")
1073 {
1074 if let Some(ref rt) = self.context_os {
1075 let my_agent = self.agent_id.read().await.clone();
1076 let category = helpers::get_str(args, "category");
1077 let key = helpers::get_str(args, "key");
1078 if let (Some(ref cat), Some(ref k)) = (&category, &key) {
1079 let recent = rt.bus.recent_by_kind(
1080 &self.workspace_id,
1081 &self.channel_id,
1082 "knowledge_remembered",
1083 20,
1084 );
1085 for ev in &recent {
1086 let p = &ev.payload;
1087 let ev_cat = p.get("category").and_then(|v| v.as_str());
1088 let ev_key = p.get("key").and_then(|v| v.as_str());
1089 let ev_actor = ev.actor.as_deref();
1090 if ev_cat == Some(cat.as_str())
1091 && ev_key == Some(k.as_str())
1092 && ev_actor != my_agent.as_deref()
1093 {
1094 let other = ev_actor.unwrap_or("unknown");
1095 result_text = format!(
1096 "[CONFLICT] Agent '{other}' recently wrote to the same knowledge key \
1097 '{cat}/{k}'. Review before proceeding.\n\n{result_text}"
1098 );
1099 break;
1100 }
1101 }
1102 }
1103 }
1104 }
1105
1106 if self.session_mode == crate::tools::SessionMode::Shared {
1108 let ws = self.workspace_id.clone();
1109 let ch = self.channel_id.clone();
1110 let rt = self.context_os.clone();
1111 let agent = self.agent_id.read().await.clone();
1112 let tool = name.to_string();
1113 let tool_action = action.clone();
1114 let tool_path = helpers::get_str(args, "path");
1115 let tool_category = helpers::get_str(args, "category");
1116 let tool_key = helpers::get_str(args, "key");
1117 let session_snapshot = self.session.read().await.clone();
1118 let session_task = session_snapshot.task.clone();
1119 tokio::task::spawn_blocking(move || {
1120 let Some(rt) = rt else {
1121 return;
1122 };
1123 let Some(root) = session_snapshot.project_root.as_deref() else {
1124 return;
1125 };
1126 rt.shared_sessions
1127 .persist_best_effort(root, &ws, &ch, &session_snapshot);
1128 rt.metrics.record_session_persisted();
1129
1130 let mut base_payload = serde_json::json!({
1131 "tool": tool,
1132 "action": tool_action,
1133 });
1134 if let Some(ref p) = tool_path {
1135 base_payload["path"] = serde_json::Value::String(p.clone());
1136 }
1137 if let Some(ref c) = tool_category {
1138 base_payload["category"] = serde_json::Value::String(c.clone());
1139 }
1140 if let Some(ref k) = tool_key {
1141 base_payload["key"] = serde_json::Value::String(k.clone());
1142 }
1143 if let Some(ref t) = session_task {
1144 base_payload["reasoning"] = serde_json::Value::String(t.description.clone());
1145 }
1146
1147 if rt
1148 .bus
1149 .append(
1150 &ws,
1151 &ch,
1152 &crate::core::context_os::ContextEventKindV1::ToolCallRecorded,
1153 agent.as_deref(),
1154 base_payload.clone(),
1155 )
1156 .is_some()
1157 {
1158 rt.metrics.record_event_appended();
1159 rt.metrics.record_event_broadcast();
1160 }
1161
1162 if let Some(secondary) =
1163 crate::core::context_os::secondary_event_kind(&tool, tool_action.as_deref())
1164 {
1165 if rt
1166 .bus
1167 .append(&ws, &ch, &secondary, agent.as_deref(), base_payload)
1168 .is_some()
1169 {
1170 rt.metrics.record_event_appended();
1171 rt.metrics.record_event_broadcast();
1172 }
1173 }
1174 });
1175 }
1176
1177 let skip_checkpoint = minimal
1178 || matches!(
1179 name,
1180 "ctx_compress"
1181 | "ctx_metrics"
1182 | "ctx_benchmark"
1183 | "ctx_analyze"
1184 | "ctx_cache"
1185 | "ctx_discover"
1186 | "ctx_dedup"
1187 | "ctx_session"
1188 | "ctx_knowledge"
1189 | "ctx_agent"
1190 | "ctx_share"
1191 | "ctx_gain"
1192 | "ctx_overview"
1193 | "ctx_preload"
1194 | "ctx_cost"
1195 | "ctx_heatmap"
1196 | "ctx_task"
1197 | "ctx_impact"
1198 | "ctx_architecture"
1199 | "ctx_smells"
1200 | "ctx_workflow"
1201 );
1202
1203 if !skip_checkpoint && self.increment_and_check() {
1204 if let Some(checkpoint) = self.auto_checkpoint().await {
1205 let interval = LeanCtxServer::checkpoint_interval_effective();
1206 let hints = crate::core::profiles::active_profile().output_hints;
1207 if hints.checkpoint_in_output() && crate::core::protocol::meta_visible() {
1208 let combined = format!(
1209 "{result_text}\n\n--- AUTO CHECKPOINT (every {interval} calls) ---\n{checkpoint}"
1210 );
1211 return Ok(CallToolResult::success(vec![Content::text(combined)]));
1212 }
1213 }
1214 }
1215
1216 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1217 if tool_duration_ms > 100 {
1218 LeanCtxServer::append_tool_call_log(
1219 name,
1220 tool_duration_ms,
1221 0,
1222 0,
1223 None,
1224 &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1225 );
1226 }
1227
1228 let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1229 if current_count > 0 && current_count.is_multiple_of(100) {
1230 std::thread::spawn(crate::cloud_sync::cloud_background_tasks);
1231 }
1232
1233 Ok(CallToolResult::success(vec![Content::text(result_text)]))
1234 }
1235}
1236
1237pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1238 crate::instructions::build_instructions_for_test(crp_mode)
1239}
1240
1241pub fn build_claude_code_instructions_for_test() -> String {
1242 crate::instructions::claude_code_instructions()
1243}
1244
1245const PROJECT_MARKERS: &[&str] = &[
1246 ".git",
1247 "Cargo.toml",
1248 "package.json",
1249 "go.mod",
1250 "pyproject.toml",
1251 "setup.py",
1252 "pom.xml",
1253 "build.gradle",
1254 "Makefile",
1255 ".lean-ctx.toml",
1256];
1257
1258fn has_project_marker(dir: &std::path::Path) -> bool {
1259 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
1260}
1261
1262fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
1263 if let Some(home) = dirs::home_dir() {
1264 if dir == home {
1265 return true;
1266 }
1267 }
1268 let dir_str = dir.to_string_lossy();
1269 dir_str.ends_with("/.claude")
1270 || dir_str.ends_with("/.codex")
1271 || dir_str.contains("/.claude/")
1272 || dir_str.contains("/.codex/")
1273}
1274
1275fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
1276 std::process::Command::new("git")
1277 .args(["rev-parse", "--show-toplevel"])
1278 .current_dir(dir)
1279 .stdout(std::process::Stdio::piped())
1280 .stderr(std::process::Stdio::null())
1281 .output()
1282 .ok()
1283 .and_then(|o| {
1284 if o.status.success() {
1285 String::from_utf8(o.stdout)
1286 .ok()
1287 .map(|s| s.trim().to_string())
1288 } else {
1289 None
1290 }
1291 })
1292}
1293
1294pub fn derive_project_root_from_cwd() -> Option<String> {
1295 let cwd = std::env::current_dir().ok()?;
1296 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
1297
1298 if is_home_or_agent_dir(&canonical) {
1299 return git_toplevel_from(&canonical);
1300 }
1301
1302 if has_project_marker(&canonical) {
1303 return Some(canonical.to_string_lossy().to_string());
1304 }
1305
1306 if let Some(git_root) = git_toplevel_from(&canonical) {
1307 return Some(git_root);
1308 }
1309
1310 if let Some(root) = detect_multi_root_workspace(&canonical) {
1311 return Some(root);
1312 }
1313
1314 if !is_broad_or_unsafe_root(&canonical) {
1318 tracing::info!(
1319 "No project markers found — using CWD as project root: {}",
1320 canonical.display()
1321 );
1322 return Some(canonical.to_string_lossy().to_string());
1323 }
1324
1325 None
1326}
1327
1328fn is_broad_or_unsafe_root(dir: &std::path::Path) -> bool {
1332 if let Some(home) = dirs::home_dir() {
1333 if dir == home {
1334 return true;
1335 }
1336 }
1337 let s = dir.to_string_lossy();
1338 if s == "/" || s == "\\" || s == "." {
1339 return true;
1340 }
1341 s.ends_with("/.claude")
1343 || s.ends_with("/.codex")
1344 || s.contains("/.claude/")
1345 || s.contains("/.codex/")
1346}
1347
1348fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
1352 let entries = std::fs::read_dir(dir).ok()?;
1353 let mut child_projects: Vec<String> = Vec::new();
1354
1355 for entry in entries.flatten() {
1356 let path = entry.path();
1357 if path.is_dir() && has_project_marker(&path) {
1358 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
1359 child_projects.push(canonical.to_string_lossy().to_string());
1360 }
1361 }
1362
1363 if child_projects.len() >= 2 {
1364 let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
1365 let sep = if cfg!(windows) { ";" } else { ":" };
1366 let merged = if existing.is_empty() {
1367 child_projects.join(sep)
1368 } else {
1369 format!("{existing}{sep}{}", child_projects.join(sep))
1370 };
1371 std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
1372 tracing::info!(
1373 "Multi-root workspace detected at {}: auto-allowing {} child projects",
1374 dir.display(),
1375 child_projects.len()
1376 );
1377 return Some(dir.to_string_lossy().to_string());
1378 }
1379
1380 None
1381}
1382
1383pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1384 crate::tool_defs::list_all_tool_defs()
1385 .into_iter()
1386 .map(|(name, desc, _)| (name, desc))
1387 .collect()
1388}
1389
1390pub fn tool_schemas_json_for_test() -> String {
1391 crate::tool_defs::list_all_tool_defs()
1392 .iter()
1393 .map(|(name, _, schema)| format!("{name}: {schema}"))
1394 .collect::<Vec<_>>()
1395 .join("\n")
1396}
1397
1398pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
1402 "ctx",
1403 "ctx_workflow",
1404 "ctx_read",
1405 "ctx_multi_read",
1406 "ctx_smart_read",
1407 "ctx_search",
1408 "ctx_tree",
1409 "ctx_session",
1410 "ctx_ledger",
1411];
1412
1413pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
1416 let elapsed = chrono::Utc::now()
1417 .signed_duration_since(run.updated_at)
1418 .num_minutes();
1419 elapsed > 30
1420}
1421
1422fn is_shell_tool_name(name: &str) -> bool {
1423 matches!(name, "ctx_shell" | "ctx_execute")
1424}
1425
1426fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
1427 let trimmed = cmd.trim();
1428 let parts: Vec<&str> = trimmed.split_whitespace().collect();
1429 if parts.len() < 2 {
1430 return None;
1431 }
1432 let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
1433 match bin {
1434 "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
1435 let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
1436 Some(file_arg.to_string())
1437 }
1438 _ => None,
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445
1446 #[test]
1447 fn project_markers_detected() {
1448 let tmp = tempfile::tempdir().unwrap();
1449 let root = tmp.path().join("myproject");
1450 std::fs::create_dir_all(&root).unwrap();
1451 assert!(!has_project_marker(&root));
1452
1453 std::fs::create_dir(root.join(".git")).unwrap();
1454 assert!(has_project_marker(&root));
1455 }
1456
1457 #[test]
1458 fn home_dir_detected_as_agent_dir() {
1459 if let Some(home) = dirs::home_dir() {
1460 assert!(is_home_or_agent_dir(&home));
1461 }
1462 }
1463
1464 #[test]
1465 fn agent_dirs_detected() {
1466 let claude = std::path::PathBuf::from("/home/user/.claude");
1467 assert!(is_home_or_agent_dir(&claude));
1468 let codex = std::path::PathBuf::from("/home/user/.codex");
1469 assert!(is_home_or_agent_dir(&codex));
1470 let project = std::path::PathBuf::from("/home/user/projects/myapp");
1471 assert!(!is_home_or_agent_dir(&project));
1472 }
1473
1474 #[test]
1475 fn test_unified_tool_count() {
1476 let tools = crate::tool_defs::unified_tool_defs();
1477 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1478 }
1479
1480 #[test]
1481 fn test_granular_tool_count() {
1482 let tools = crate::tool_defs::granular_tool_defs();
1483 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1484 }
1485
1486 #[test]
1487 fn test_registry_tool_count_ssot() {
1488 let registry = crate::server::registry::build_registry();
1489 assert_eq!(
1490 registry.len(),
1491 61,
1492 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
1493 );
1494 }
1495
1496 #[test]
1497 fn disabled_tools_filters_list() {
1498 let all = crate::tool_defs::granular_tool_defs();
1499 let total = all.len();
1500 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
1501 let filtered: Vec<_> = all
1502 .into_iter()
1503 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1504 .collect();
1505 assert_eq!(filtered.len(), total - 2);
1506 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1507 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1508 }
1509
1510 #[test]
1511 fn empty_disabled_tools_returns_all() {
1512 let all = crate::tool_defs::granular_tool_defs();
1513 let total = all.len();
1514 let disabled: Vec<String> = vec![];
1515 let filtered: Vec<_> = all
1516 .into_iter()
1517 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1518 .collect();
1519 assert_eq!(filtered.len(), total);
1520 }
1521
1522 #[test]
1523 fn misspelled_disabled_tool_is_silently_ignored() {
1524 let all = crate::tool_defs::granular_tool_defs();
1525 let total = all.len();
1526 let disabled = ["ctx_nonexistent_tool".to_string()];
1527 let filtered: Vec<_> = all
1528 .into_iter()
1529 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1530 .collect();
1531 assert_eq!(filtered.len(), total);
1532 }
1533
1534 #[test]
1535 fn detect_multi_root_workspace_with_child_projects() {
1536 let tmp = tempfile::tempdir().unwrap();
1537 let workspace = tmp.path().join("workspace");
1538 std::fs::create_dir_all(&workspace).unwrap();
1539
1540 let proj_a = workspace.join("project-a");
1541 let proj_b = workspace.join("project-b");
1542 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1543 std::fs::create_dir_all(&proj_b).unwrap();
1544 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
1545
1546 let result = detect_multi_root_workspace(&workspace);
1547 assert!(
1548 result.is_some(),
1549 "should detect workspace with 2 child projects"
1550 );
1551
1552 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
1553 }
1554
1555 #[test]
1556 fn detect_multi_root_workspace_returns_none_for_single_project() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 let workspace = tmp.path().join("workspace");
1559 std::fs::create_dir_all(&workspace).unwrap();
1560
1561 let proj_a = workspace.join("project-a");
1562 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1563
1564 let result = detect_multi_root_workspace(&workspace);
1565 assert!(
1566 result.is_none(),
1567 "should not detect workspace with only 1 child project"
1568 );
1569 }
1570
1571 #[test]
1572 fn is_broad_or_unsafe_root_rejects_home() {
1573 if let Some(home) = dirs::home_dir() {
1574 assert!(is_broad_or_unsafe_root(&home));
1575 }
1576 }
1577
1578 #[test]
1579 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
1580 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
1581 }
1582
1583 #[test]
1584 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
1585 assert!(is_broad_or_unsafe_root(std::path::Path::new(
1586 "/home/user/.claude"
1587 )));
1588 assert!(is_broad_or_unsafe_root(std::path::Path::new(
1589 "/home/user/.codex"
1590 )));
1591 }
1592
1593 #[test]
1594 fn is_broad_or_unsafe_root_allows_project_subdir() {
1595 let tmp = tempfile::tempdir().unwrap();
1596 let subdir = tmp.path().join("my-project");
1597 std::fs::create_dir_all(&subdir).unwrap();
1598 assert!(!is_broad_or_unsafe_root(&subdir));
1599 }
1600
1601 #[test]
1602 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
1603 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1604 "/tmp/leanctx-test"
1605 )));
1606 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1607 "/tmp/my-project"
1608 )));
1609 }
1610
1611 #[test]
1612 fn is_broad_or_unsafe_root_allows_home_subdirs() {
1613 if let Some(home) = dirs::home_dir() {
1614 let subdir = home.join("projects").join("my-app");
1615 assert!(!is_broad_or_unsafe_root(&subdir));
1616 }
1617 }
1618
1619 #[test]
1620 fn derive_project_root_falls_back_to_bare_cwd() {
1621 let tmp = tempfile::tempdir().unwrap();
1622 let bare = tmp.path().join("bare-dir");
1623 std::fs::create_dir_all(&bare).unwrap();
1624
1625 let original = std::env::current_dir().unwrap();
1626 std::env::set_current_dir(&bare).unwrap();
1627 let result = derive_project_root_from_cwd();
1628 std::env::set_current_dir(original).unwrap();
1629
1630 assert!(result.is_some(), "bare dir should produce a project root");
1631 let root = result.unwrap();
1632 assert!(
1633 root.contains("bare-dir"),
1634 "fallback should use the bare dir path"
1635 );
1636 }
1637}