1use rmcp::handler::server::ServerHandler;
2use rmcp::model::*;
3use rmcp::service::{RequestContext, RoleServer};
4use rmcp::ErrorData;
5use serde_json::Value;
6
7use crate::tools::{CrpMode, LeanCtxServer};
8
9impl ServerHandler for LeanCtxServer {
10 fn get_info(&self) -> ServerInfo {
11 let capabilities = ServerCapabilities::builder().enable_tools().build();
12
13 let instructions = crate::instructions::build_instructions(self.crp_mode);
14
15 InitializeResult::new(capabilities)
16 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
17 .with_instructions(instructions)
18 }
19
20 async fn initialize(
21 &self,
22 request: InitializeRequestParams,
23 _context: RequestContext<RoleServer>,
24 ) -> Result<InitializeResult, ErrorData> {
25 let name = request.client_info.name.clone();
26 tracing::info!("MCP client connected: {:?}", name);
27 *self.client_name.write().await = name.clone();
28
29 tokio::task::spawn_blocking(|| {
30 if let Some(home) = dirs::home_dir() {
31 let _ = crate::rules_inject::inject_all_rules(&home);
32 }
33 crate::hooks::refresh_installed_hooks();
34 crate::core::version_check::check_background();
35 });
36
37 let instructions =
38 crate::instructions::build_instructions_with_client(self.crp_mode, &name);
39 let capabilities = ServerCapabilities::builder().enable_tools().build();
40
41 Ok(InitializeResult::new(capabilities)
42 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
43 .with_instructions(instructions))
44 }
45
46 async fn list_tools(
47 &self,
48 _request: Option<PaginatedRequestParams>,
49 _context: RequestContext<RoleServer>,
50 ) -> Result<ListToolsResult, ErrorData> {
51 let all_tools = if std::env::var("LEAN_CTX_UNIFIED").is_ok()
52 && std::env::var("LEAN_CTX_FULL_TOOLS").is_err()
53 {
54 crate::tool_defs::unified_tool_defs()
55 } else {
56 crate::tool_defs::granular_tool_defs()
57 };
58
59 let disabled = crate::core::config::Config::load().disabled_tools_effective();
60 let tools = if disabled.is_empty() {
61 all_tools
62 } else {
63 all_tools
64 .into_iter()
65 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
66 .collect()
67 };
68
69 Ok(ListToolsResult {
70 tools,
71 ..Default::default()
72 })
73 }
74
75 async fn call_tool(
76 &self,
77 request: CallToolRequestParams,
78 _context: RequestContext<RoleServer>,
79 ) -> Result<CallToolResult, ErrorData> {
80 self.check_idle_expiry().await;
81
82 let original_name = request.name.as_ref().to_string();
83 let (resolved_name, resolved_args) = if original_name == "ctx" {
84 let sub = request
85 .arguments
86 .as_ref()
87 .and_then(|a| a.get("tool"))
88 .and_then(|v| v.as_str())
89 .map(|s| s.to_string())
90 .ok_or_else(|| {
91 ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
92 })?;
93 let tool_name = if sub.starts_with("ctx_") {
94 sub
95 } else {
96 format!("ctx_{sub}")
97 };
98 let mut args = request.arguments.unwrap_or_default();
99 args.remove("tool");
100 (tool_name, Some(args))
101 } else {
102 (original_name, request.arguments)
103 };
104 let name = resolved_name.as_str();
105 let args = &resolved_args;
106
107 let auto_context = {
108 let task = {
109 let session = self.session.read().await;
110 session.task.as_ref().map(|t| t.description.clone())
111 };
112 let project_root = {
113 let session = self.session.read().await;
114 session.project_root.clone()
115 };
116 let mut cache = self.cache.write().await;
117 crate::tools::autonomy::session_lifecycle_pre_hook(
118 &self.autonomy,
119 name,
120 &mut cache,
121 task.as_deref(),
122 project_root.as_deref(),
123 self.crp_mode,
124 )
125 };
126
127 let throttle_result = {
128 let fp = args
129 .as_ref()
130 .map(|a| {
131 crate::core::loop_detection::LoopDetector::fingerprint(
132 &serde_json::Value::Object(a.clone()),
133 )
134 })
135 .unwrap_or_default();
136 let mut detector = self.loop_detector.write().await;
137
138 let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
139 let is_search_shell = name == "ctx_shell" && {
140 let cmd = args
141 .as_ref()
142 .and_then(|a| a.get("command"))
143 .and_then(|v| v.as_str())
144 .unwrap_or("");
145 crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
146 };
147
148 if is_search || is_search_shell {
149 let search_pattern = args.as_ref().and_then(|a| {
150 a.get("pattern")
151 .or_else(|| a.get("query"))
152 .and_then(|v| v.as_str())
153 });
154 let shell_pattern = if is_search_shell {
155 args.as_ref()
156 .and_then(|a| a.get("command"))
157 .and_then(|v| v.as_str())
158 .and_then(extract_search_pattern_from_command)
159 } else {
160 None
161 };
162 let pat = search_pattern.or(shell_pattern.as_deref());
163 detector.record_search(name, &fp, pat)
164 } else {
165 detector.record_call(name, &fp)
166 }
167 };
168
169 if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
170 let msg = throttle_result.message.unwrap_or_default();
171 return Ok(CallToolResult::success(vec![Content::text(msg)]));
172 }
173
174 let throttle_warning =
175 if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
176 throttle_result.message.clone()
177 } else {
178 None
179 };
180
181 let tool_start = std::time::Instant::now();
182 let result_text = match name {
183 "ctx_read" => {
184 let path = match get_str(args, "path") {
185 Some(p) => self.resolve_path(&p).await,
186 None => return Err(ErrorData::invalid_params("path is required", None)),
187 };
188 let current_task = {
189 let session = self.session.read().await;
190 session.task.as_ref().map(|t| t.description.clone())
191 };
192 let task_ref = current_task.as_deref();
193 let mut mode = match get_str(args, "mode") {
194 Some(m) => m,
195 None => {
196 let cache = self.cache.read().await;
197 crate::tools::ctx_smart_read::select_mode_with_task(&cache, &path, task_ref)
198 }
199 };
200 let fresh = get_bool(args, "fresh").unwrap_or(false);
201 let start_line = get_int(args, "start_line");
202 if let Some(sl) = start_line {
203 let sl = sl.max(1_i64);
204 mode = format!("lines:{sl}-999999");
205 }
206 let stale = self.is_prompt_cache_stale().await;
207 let effective_mode = LeanCtxServer::upgrade_mode_if_stale(&mode, stale).to_string();
208 let mut cache = self.cache.write().await;
209 let output = if fresh {
210 crate::tools::ctx_read::handle_fresh_with_task(
211 &mut cache,
212 &path,
213 &effective_mode,
214 self.crp_mode,
215 task_ref,
216 )
217 } else {
218 crate::tools::ctx_read::handle_with_task(
219 &mut cache,
220 &path,
221 &effective_mode,
222 self.crp_mode,
223 task_ref,
224 )
225 };
226 let stale_note = if effective_mode != mode {
227 format!("[cache stale, {mode}→{effective_mode}]\n")
228 } else {
229 String::new()
230 };
231 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
232 let output_tokens = crate::core::tokens::count_tokens(&output);
233 let saved = original.saturating_sub(output_tokens);
234 let is_cache_hit = output.contains(" cached ");
235 let output = format!("{stale_note}{output}");
236 let file_ref = cache.file_ref_map().get(&path).cloned();
237 drop(cache);
238 let mut ensured_root: Option<String> = None;
239 {
240 let mut session = self.session.write().await;
241 session.touch_file(&path, file_ref.as_deref(), &effective_mode, original);
242 if is_cache_hit {
243 session.record_cache_hit();
244 }
245 let root_missing = session
246 .project_root
247 .as_deref()
248 .map(|r| r.trim().is_empty())
249 .unwrap_or(true);
250 if root_missing {
251 if let Some(root) = crate::core::protocol::detect_project_root(&path) {
252 session.project_root = Some(root.clone());
253 ensured_root = Some(root.clone());
254 let mut current = self.agent_id.write().await;
255 if current.is_none() {
256 let mut registry =
257 crate::core::agents::AgentRegistry::load_or_create();
258 registry.cleanup_stale(24);
259 let role = std::env::var("LEAN_CTX_AGENT_ROLE").ok();
260 let id = registry.register("mcp", role.as_deref(), &root);
261 let _ = registry.save();
262 *current = Some(id);
263 }
264 }
265 }
266 }
267 if let Some(root) = ensured_root.as_deref() {
268 crate::core::index_orchestrator::ensure_all_background(root);
269 }
270 self.record_call("ctx_read", original, saved, Some(mode.clone()))
271 .await;
272 {
273 let sig =
274 crate::core::mode_predictor::FileSignature::from_path(&path, original);
275 let density = if output_tokens > 0 {
276 original as f64 / output_tokens as f64
277 } else {
278 1.0
279 };
280 let outcome = crate::core::mode_predictor::ModeOutcome {
281 mode: mode.clone(),
282 tokens_in: original,
283 tokens_out: output_tokens,
284 density: density.min(1.0),
285 };
286 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
287 predictor.record(sig, outcome);
288 predictor.save();
289
290 let ext = std::path::Path::new(&path)
291 .extension()
292 .and_then(|e| e.to_str())
293 .unwrap_or("")
294 .to_string();
295 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(&path);
296 let cache = self.cache.read().await;
297 let stats = cache.get_stats();
298 let feedback_outcome = crate::core::feedback::CompressionOutcome {
299 session_id: format!("{}", std::process::id()),
300 language: ext,
301 entropy_threshold: thresholds.bpe_entropy,
302 jaccard_threshold: thresholds.jaccard,
303 total_turns: stats.total_reads as u32,
304 tokens_saved: saved as u64,
305 tokens_original: original as u64,
306 cache_hits: stats.cache_hits as u32,
307 total_reads: stats.total_reads as u32,
308 task_completed: true,
309 timestamp: chrono::Local::now().to_rfc3339(),
310 };
311 drop(cache);
312 let mut store = crate::core::feedback::FeedbackStore::load();
313 store.record_outcome(feedback_outcome);
314 }
315 output
316 }
317 "ctx_multi_read" => {
318 let raw_paths = get_str_array(args, "paths")
319 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
320 let mut paths = Vec::with_capacity(raw_paths.len());
321 for p in raw_paths {
322 paths.push(self.resolve_path(&p).await);
323 }
324 let mode = get_str(args, "mode").unwrap_or_else(|| "full".to_string());
325 let current_task = {
326 let session = self.session.read().await;
327 session.task.as_ref().map(|t| t.description.clone())
328 };
329 let mut cache = self.cache.write().await;
330 let output = crate::tools::ctx_multi_read::handle_with_task(
331 &mut cache,
332 &paths,
333 &mode,
334 self.crp_mode,
335 current_task.as_deref(),
336 );
337 let mut total_original: usize = 0;
338 for path in &paths {
339 total_original = total_original
340 .saturating_add(cache.get(path).map(|e| e.original_tokens).unwrap_or(0));
341 }
342 let tokens = crate::core::tokens::count_tokens(&output);
343 drop(cache);
344 self.record_call(
345 "ctx_multi_read",
346 total_original,
347 total_original.saturating_sub(tokens),
348 Some(mode),
349 )
350 .await;
351 output
352 }
353 "ctx_tree" => {
354 let path = self
355 .resolve_path(&get_str(args, "path").unwrap_or_else(|| ".".to_string()))
356 .await;
357 let depth = get_int(args, "depth").unwrap_or(3) as usize;
358 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
359 let (result, original) = crate::tools::ctx_tree::handle(&path, depth, show_hidden);
360 let sent = crate::core::tokens::count_tokens(&result);
361 let saved = original.saturating_sub(sent);
362 self.record_call("ctx_tree", original, saved, None).await;
363 let savings_note = if saved > 0 {
364 format!("\n[saved {saved} tokens vs native ls]")
365 } else {
366 String::new()
367 };
368 format!("{result}{savings_note}")
369 }
370 "ctx_shell" => {
371 let command = get_str(args, "command")
372 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
373
374 if let Some(rejection) = crate::tools::ctx_shell::validate_command(&command) {
375 self.record_call("ctx_shell", 0, 0, None).await;
376 return Ok(CallToolResult::success(vec![Content::text(rejection)]));
377 }
378
379 let explicit_cwd = get_str(args, "cwd");
380 let effective_cwd = {
381 let session = self.session.read().await;
382 session.effective_cwd(explicit_cwd.as_deref())
383 };
384
385 let ensured_root = {
386 let mut session = self.session.write().await;
387 session.update_shell_cwd(&command);
388 let root_missing = session
389 .project_root
390 .as_deref()
391 .map(|r| r.trim().is_empty())
392 .unwrap_or(true);
393 if !root_missing {
394 None
395 } else {
396 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
397 crate::core::protocol::detect_project_root(&effective_cwd).and_then(|r| {
398 if home.as_deref() == Some(r.as_str()) {
399 None
400 } else {
401 session.project_root = Some(r.clone());
402 Some(r)
403 }
404 })
405 }
406 };
407 if let Some(root) = ensured_root.as_deref() {
408 crate::core::index_orchestrator::ensure_all_background(root);
409 let mut current = self.agent_id.write().await;
410 if current.is_none() {
411 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
412 registry.cleanup_stale(24);
413 let role = std::env::var("LEAN_CTX_AGENT_ROLE").ok();
414 let id = registry.register("mcp", role.as_deref(), root);
415 let _ = registry.save();
416 *current = Some(id);
417 }
418 }
419
420 let raw = get_bool(args, "raw").unwrap_or(false)
421 || std::env::var("LEAN_CTX_DISABLED").is_ok();
422 let cmd_clone = command.clone();
423 let cwd_clone = effective_cwd.clone();
424 let (output, real_exit_code) =
425 tokio::task::spawn_blocking(move || execute_command_in(&cmd_clone, &cwd_clone))
426 .await
427 .unwrap_or_else(|e| (format!("ERROR: shell task failed: {e}"), 1));
428
429 if raw {
430 let original = crate::core::tokens::count_tokens(&output);
431 self.record_call("ctx_shell", original, 0, None).await;
432 output
433 } else {
434 let result = crate::tools::ctx_shell::handle(&command, &output, self.crp_mode);
435 let original = crate::core::tokens::count_tokens(&output);
436 let sent = crate::core::tokens::count_tokens(&result);
437 let saved = original.saturating_sub(sent);
438 self.record_call("ctx_shell", original, saved, None).await;
439
440 let cfg = crate::core::config::Config::load();
441 let tee_hint = match cfg.tee_mode {
442 crate::core::config::TeeMode::Always => {
443 crate::shell::save_tee(&command, &output)
444 .map(|p| format!("\n[full output: {p}]"))
445 .unwrap_or_default()
446 }
447 crate::core::config::TeeMode::Failures
448 if !output.trim().is_empty() && output.contains("error")
449 || output.contains("Error")
450 || output.contains("ERROR") =>
451 {
452 crate::shell::save_tee(&command, &output)
453 .map(|p| format!("\n[full output: {p}]"))
454 .unwrap_or_default()
455 }
456 _ => String::new(),
457 };
458
459 let savings_note = if saved > 0 {
460 format!("\n[saved {saved} tokens vs native Shell]")
461 } else {
462 String::new()
463 };
464
465 {
467 let sess = self.session.read().await;
468 let root = sess.project_root.clone();
469 let sid = sess.id.clone();
470 let files: Vec<String> = sess
471 .files_touched
472 .iter()
473 .map(|ft| ft.path.clone())
474 .collect();
475 drop(sess);
476
477 if let Some(ref root) = root {
478 let mut store = crate::core::gotcha_tracker::GotchaStore::load(root);
479
480 if real_exit_code != 0 {
481 store.detect_error(&output, &command, real_exit_code, &files, &sid);
482 } else {
483 let relevant = store.top_relevant(&files, 7);
485 let relevant_ids: Vec<String> =
486 relevant.iter().map(|g| g.id.clone()).collect();
487 for gid in &relevant_ids {
488 store.mark_prevented(gid);
489 }
490
491 if store.try_resolve_pending(&command, &files, &sid).is_some() {
492 store.cross_session_boost();
493 }
494
495 let promotions = store.check_promotions();
497 if !promotions.is_empty() {
498 let mut knowledge =
499 crate::core::knowledge::ProjectKnowledge::load_or_create(
500 root,
501 );
502 for (cat, trigger, resolution, conf) in &promotions {
503 knowledge.remember(
504 &format!("gotcha-{cat}"),
505 trigger,
506 resolution,
507 &sid,
508 *conf,
509 );
510 }
511 let _ = knowledge.save();
512 }
513 }
514
515 let _ = store.save(root);
516 }
517 }
518
519 format!("{result}{savings_note}{tee_hint}")
520 }
521 }
522 "ctx_search" => {
523 let pattern = get_str(args, "pattern")
524 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
525 let path = self
526 .resolve_path(&get_str(args, "path").unwrap_or_else(|| ".".to_string()))
527 .await;
528 let ext = get_str(args, "ext");
529 let max = get_int(args, "max_results").unwrap_or(20) as usize;
530 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
531 let crp = self.crp_mode;
532 let respect = !no_gitignore;
533 let search_result = tokio::time::timeout(
534 std::time::Duration::from_secs(30),
535 tokio::task::spawn_blocking(move || {
536 crate::tools::ctx_search::handle(
537 &pattern,
538 &path,
539 ext.as_deref(),
540 max,
541 crp,
542 respect,
543 )
544 }),
545 )
546 .await;
547 let (result, original) = match search_result {
548 Ok(Ok(r)) => r,
549 Ok(Err(e)) => {
550 return Err(ErrorData::internal_error(
551 format!("search task failed: {e}"),
552 None,
553 ))
554 }
555 Err(_) => {
556 let msg = "ctx_search timed out after 30s. Try narrowing the search:\n\
557 • Use a more specific pattern\n\
558 • Specify ext= to limit file types\n\
559 • Specify a subdirectory in path=";
560 self.record_call("ctx_search", 0, 0, None).await;
561 return Ok(CallToolResult::success(vec![Content::text(msg)]));
562 }
563 };
564 let sent = crate::core::tokens::count_tokens(&result);
565 let saved = original.saturating_sub(sent);
566 self.record_call("ctx_search", original, saved, None).await;
567 let savings_note = if saved > 0 {
568 format!("\n[saved {saved} tokens vs native Grep]")
569 } else {
570 String::new()
571 };
572 format!("{result}{savings_note}")
573 }
574 "ctx_compress" => {
575 let include_sigs = get_bool(args, "include_signatures").unwrap_or(true);
576 let cache = self.cache.read().await;
577 let result =
578 crate::tools::ctx_compress::handle(&cache, include_sigs, self.crp_mode);
579 drop(cache);
580 self.record_call("ctx_compress", 0, 0, None).await;
581 result
582 }
583 "ctx_benchmark" => {
584 let path = match get_str(args, "path") {
585 Some(p) => self.resolve_path(&p).await,
586 None => return Err(ErrorData::invalid_params("path is required", None)),
587 };
588 let action = get_str(args, "action").unwrap_or_default();
589 let result = if action == "project" {
590 let fmt = get_str(args, "format").unwrap_or_default();
591 let bench = crate::core::benchmark::run_project_benchmark(&path);
592 match fmt.as_str() {
593 "json" => crate::core::benchmark::format_json(&bench),
594 "markdown" | "md" => crate::core::benchmark::format_markdown(&bench),
595 _ => crate::core::benchmark::format_terminal(&bench),
596 }
597 } else {
598 crate::tools::ctx_benchmark::handle(&path, self.crp_mode)
599 };
600 self.record_call("ctx_benchmark", 0, 0, None).await;
601 result
602 }
603 "ctx_metrics" => {
604 let cache = self.cache.read().await;
605 let calls = self.tool_calls.read().await;
606 let result = crate::tools::ctx_metrics::handle(&cache, &calls, self.crp_mode);
607 drop(cache);
608 drop(calls);
609 self.record_call("ctx_metrics", 0, 0, None).await;
610 result
611 }
612 "ctx_analyze" => {
613 let path = match get_str(args, "path") {
614 Some(p) => self.resolve_path(&p).await,
615 None => return Err(ErrorData::invalid_params("path is required", None)),
616 };
617 let result = crate::tools::ctx_analyze::handle(&path, self.crp_mode);
618 self.record_call("ctx_analyze", 0, 0, None).await;
619 result
620 }
621 "ctx_discover" => {
622 let limit = get_int(args, "limit").unwrap_or(15) as usize;
623 let history = crate::cli::load_shell_history_pub();
624 let result = crate::tools::ctx_discover::discover_from_history(&history, limit);
625 self.record_call("ctx_discover", 0, 0, None).await;
626 result
627 }
628 "ctx_smart_read" => {
629 let path = match get_str(args, "path") {
630 Some(p) => self.resolve_path(&p).await,
631 None => return Err(ErrorData::invalid_params("path is required", None)),
632 };
633 let mut cache = self.cache.write().await;
634 let output = crate::tools::ctx_smart_read::handle(&mut cache, &path, self.crp_mode);
635 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
636 let tokens = crate::core::tokens::count_tokens(&output);
637 drop(cache);
638 self.record_call(
639 "ctx_smart_read",
640 original,
641 original.saturating_sub(tokens),
642 Some("auto".to_string()),
643 )
644 .await;
645 output
646 }
647 "ctx_delta" => {
648 let path = match get_str(args, "path") {
649 Some(p) => self.resolve_path(&p).await,
650 None => return Err(ErrorData::invalid_params("path is required", None)),
651 };
652 let mut cache = self.cache.write().await;
653 let output = crate::tools::ctx_delta::handle(&mut cache, &path);
654 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
655 let tokens = crate::core::tokens::count_tokens(&output);
656 drop(cache);
657 {
658 let mut session = self.session.write().await;
659 session.mark_modified(&path);
660 }
661 self.record_call(
662 "ctx_delta",
663 original,
664 original.saturating_sub(tokens),
665 Some("delta".to_string()),
666 )
667 .await;
668 output
669 }
670 "ctx_edit" => {
671 let path = match get_str(args, "path") {
672 Some(p) => self.resolve_path(&p).await,
673 None => return Err(ErrorData::invalid_params("path is required", None)),
674 };
675 let old_string = get_str(args, "old_string").unwrap_or_default();
676 let new_string = get_str(args, "new_string")
677 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
678 let replace_all = args
679 .as_ref()
680 .and_then(|a| a.get("replace_all"))
681 .and_then(|v| v.as_bool())
682 .unwrap_or(false);
683 let create = args
684 .as_ref()
685 .and_then(|a| a.get("create"))
686 .and_then(|v| v.as_bool())
687 .unwrap_or(false);
688
689 let mut cache = self.cache.write().await;
690 let output = crate::tools::ctx_edit::handle(
691 &mut cache,
692 crate::tools::ctx_edit::EditParams {
693 path: path.clone(),
694 old_string,
695 new_string,
696 replace_all,
697 create,
698 },
699 );
700 drop(cache);
701
702 {
703 let mut session = self.session.write().await;
704 session.mark_modified(&path);
705 }
706 self.record_call("ctx_edit", 0, 0, None).await;
707 output
708 }
709 "ctx_dedup" => {
710 let action = get_str(args, "action").unwrap_or_default();
711 if action == "apply" {
712 let mut cache = self.cache.write().await;
713 let result = crate::tools::ctx_dedup::handle_action(&mut cache, &action);
714 drop(cache);
715 self.record_call("ctx_dedup", 0, 0, None).await;
716 result
717 } else {
718 let cache = self.cache.read().await;
719 let result = crate::tools::ctx_dedup::handle(&cache);
720 drop(cache);
721 self.record_call("ctx_dedup", 0, 0, None).await;
722 result
723 }
724 }
725 "ctx_fill" => {
726 let raw_paths = get_str_array(args, "paths")
727 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
728 let mut paths = Vec::with_capacity(raw_paths.len());
729 for p in raw_paths {
730 paths.push(self.resolve_path(&p).await);
731 }
732 let budget = get_int(args, "budget")
733 .ok_or_else(|| ErrorData::invalid_params("budget is required", None))?
734 as usize;
735 let mut cache = self.cache.write().await;
736 let output =
737 crate::tools::ctx_fill::handle(&mut cache, &paths, budget, self.crp_mode);
738 drop(cache);
739 self.record_call("ctx_fill", 0, 0, Some(format!("budget:{budget}")))
740 .await;
741 output
742 }
743 "ctx_intent" => {
744 let query = get_str(args, "query")
745 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
746 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
747 let mut cache = self.cache.write().await;
748 let output =
749 crate::tools::ctx_intent::handle(&mut cache, &query, &root, self.crp_mode);
750 drop(cache);
751 {
752 let mut session = self.session.write().await;
753 session.set_task(&query, Some("intent"));
754 }
755 self.record_call("ctx_intent", 0, 0, Some("semantic".to_string()))
756 .await;
757 output
758 }
759 "ctx_response" => {
760 let text = get_str(args, "text")
761 .ok_or_else(|| ErrorData::invalid_params("text is required", None))?;
762 let output = crate::tools::ctx_response::handle(&text, self.crp_mode);
763 self.record_call("ctx_response", 0, 0, None).await;
764 output
765 }
766 "ctx_context" => {
767 let cache = self.cache.read().await;
768 let turn = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
769 let result = crate::tools::ctx_context::handle_status(&cache, turn, self.crp_mode);
770 drop(cache);
771 self.record_call("ctx_context", 0, 0, None).await;
772 result
773 }
774 "ctx_graph" => {
775 let action = get_str(args, "action")
776 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
777 let path = match get_str(args, "path") {
778 Some(p) => Some(self.resolve_path(&p).await),
779 None => None,
780 };
781 let root = self
782 .resolve_path(&get_str(args, "project_root").unwrap_or_else(|| ".".to_string()))
783 .await;
784 let mut cache = self.cache.write().await;
785 let result = crate::tools::ctx_graph::handle(
786 &action,
787 path.as_deref(),
788 &root,
789 &mut cache,
790 self.crp_mode,
791 );
792 drop(cache);
793 self.record_call("ctx_graph", 0, 0, Some(action)).await;
794 result
795 }
796 "ctx_cache" => {
797 let action = get_str(args, "action")
798 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
799 let mut cache = self.cache.write().await;
800 let result = match action.as_str() {
801 "status" => {
802 let entries = cache.get_all_entries();
803 if entries.is_empty() {
804 "Cache empty — no files tracked.".to_string()
805 } else {
806 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
807 for (path, entry) in &entries {
808 let fref = cache
809 .file_ref_map()
810 .get(*path)
811 .map(|s| s.as_str())
812 .unwrap_or("F?");
813 lines.push(format!(
814 " {fref}={} [{}L, {}t, read {}x]",
815 crate::core::protocol::shorten_path(path),
816 entry.line_count,
817 entry.original_tokens,
818 entry.read_count
819 ));
820 }
821 lines.join("\n")
822 }
823 }
824 "clear" => {
825 let count = cache.clear();
826 format!("Cache cleared — {count} file(s) removed. Next ctx_read will return full content.")
827 }
828 "invalidate" => {
829 let path = match get_str(args, "path") {
830 Some(p) => self.resolve_path(&p).await,
831 None => {
832 return Err(ErrorData::invalid_params(
833 "path is required for invalidate",
834 None,
835 ))
836 }
837 };
838 if cache.invalidate(&path) {
839 format!(
840 "Invalidated cache for {}. Next ctx_read will return full content.",
841 crate::core::protocol::shorten_path(&path)
842 )
843 } else {
844 format!(
845 "{} was not in cache.",
846 crate::core::protocol::shorten_path(&path)
847 )
848 }
849 }
850 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
851 };
852 drop(cache);
853 self.record_call("ctx_cache", 0, 0, Some(action)).await;
854 result
855 }
856 "ctx_session" => {
857 let action = get_str(args, "action")
858 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
859 let value = get_str(args, "value");
860 let sid = get_str(args, "session_id");
861 let mut session = self.session.write().await;
862 let result = crate::tools::ctx_session::handle(
863 &mut session,
864 &action,
865 value.as_deref(),
866 sid.as_deref(),
867 );
868 drop(session);
869 self.record_call("ctx_session", 0, 0, Some(action)).await;
870 result
871 }
872 "ctx_knowledge" => {
873 let action = get_str(args, "action")
874 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
875 let category = get_str(args, "category");
876 let key = get_str(args, "key");
877 let value = get_str(args, "value");
878 let query = get_str(args, "query");
879 let pattern_type = get_str(args, "pattern_type");
880 let examples = get_str_array(args, "examples");
881 let confidence: Option<f32> = args
882 .as_ref()
883 .and_then(|a| a.get("confidence"))
884 .and_then(|v| v.as_f64())
885 .map(|v| v as f32);
886
887 let session = self.session.read().await;
888 let session_id = session.id.clone();
889 let project_root = session.project_root.clone().unwrap_or_else(|| {
890 std::env::current_dir()
891 .map(|p| p.to_string_lossy().to_string())
892 .unwrap_or_else(|_| "unknown".to_string())
893 });
894 drop(session);
895
896 if action == "gotcha" {
897 let trigger = get_str(args, "trigger").unwrap_or_default();
898 let resolution = get_str(args, "resolution").unwrap_or_default();
899 let severity = get_str(args, "severity").unwrap_or_default();
900 let cat = category.as_deref().unwrap_or("convention");
901
902 if trigger.is_empty() || resolution.is_empty() {
903 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
904 return Ok(CallToolResult::success(vec![Content::text(
905 "ERROR: trigger and resolution are required for gotcha action",
906 )]));
907 }
908
909 let mut store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
910 let msg = match store.report_gotcha(
911 &trigger,
912 &resolution,
913 cat,
914 &severity,
915 &session_id,
916 ) {
917 Some(gotcha) => {
918 let conf = (gotcha.confidence * 100.0) as u32;
919 let label = gotcha.category.short_label();
920 format!("Gotcha recorded: [{label}] {trigger} (confidence: {conf}%)")
921 }
922 None => format!(
923 "Gotcha noted: {trigger} (evicted by higher-confidence entries)"
924 ),
925 };
926 let _ = store.save(&project_root);
927 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
928 return Ok(CallToolResult::success(vec![Content::text(msg)]));
929 }
930
931 let result = crate::tools::ctx_knowledge::handle(
932 &project_root,
933 &action,
934 category.as_deref(),
935 key.as_deref(),
936 value.as_deref(),
937 query.as_deref(),
938 &session_id,
939 pattern_type.as_deref(),
940 examples,
941 confidence,
942 );
943 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
944 result
945 }
946 "ctx_agent" => {
947 let action = get_str(args, "action")
948 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
949 let agent_type = get_str(args, "agent_type");
950 let role = get_str(args, "role");
951 let message = get_str(args, "message");
952 let category = get_str(args, "category");
953 let to_agent = get_str(args, "to_agent");
954 let status = get_str(args, "status");
955
956 let session = self.session.read().await;
957 let project_root = session.project_root.clone().unwrap_or_else(|| {
958 std::env::current_dir()
959 .map(|p| p.to_string_lossy().to_string())
960 .unwrap_or_else(|_| "unknown".to_string())
961 });
962 drop(session);
963
964 let current_agent_id = self.agent_id.read().await.clone();
965 let result = crate::tools::ctx_agent::handle(
966 &action,
967 agent_type.as_deref(),
968 role.as_deref(),
969 &project_root,
970 current_agent_id.as_deref(),
971 message.as_deref(),
972 category.as_deref(),
973 to_agent.as_deref(),
974 status.as_deref(),
975 );
976
977 if action == "register" {
978 if let Some(id) = result.split(':').nth(1) {
979 let id = id.split_whitespace().next().unwrap_or("").to_string();
980 if !id.is_empty() {
981 *self.agent_id.write().await = Some(id);
982 }
983 }
984 }
985
986 self.record_call("ctx_agent", 0, 0, Some(action)).await;
987 result
988 }
989 "ctx_share" => {
990 let action = get_str(args, "action")
991 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
992 let to_agent = get_str(args, "to_agent");
993 let paths = get_str(args, "paths");
994 let message = get_str(args, "message");
995
996 let from_agent = self.agent_id.read().await.clone();
997 let cache = self.cache.read().await;
998 let result = crate::tools::ctx_share::handle(
999 &action,
1000 from_agent.as_deref(),
1001 to_agent.as_deref(),
1002 paths.as_deref(),
1003 message.as_deref(),
1004 &cache,
1005 );
1006 drop(cache);
1007
1008 self.record_call("ctx_share", 0, 0, Some(action)).await;
1009 result
1010 }
1011 "ctx_overview" => {
1012 let task = get_str(args, "task");
1013 let resolved_path = match get_str(args, "path") {
1014 Some(p) => Some(self.resolve_path(&p).await),
1015 None => {
1016 let session = self.session.read().await;
1017 session.project_root.clone()
1018 }
1019 };
1020 let cache = self.cache.read().await;
1021 let result = crate::tools::ctx_overview::handle(
1022 &cache,
1023 task.as_deref(),
1024 resolved_path.as_deref(),
1025 self.crp_mode,
1026 );
1027 drop(cache);
1028 self.record_call("ctx_overview", 0, 0, Some("overview".to_string()))
1029 .await;
1030 result
1031 }
1032 "ctx_preload" => {
1033 let task = get_str(args, "task").unwrap_or_default();
1034 let resolved_path = match get_str(args, "path") {
1035 Some(p) => Some(self.resolve_path(&p).await),
1036 None => {
1037 let session = self.session.read().await;
1038 session.project_root.clone()
1039 }
1040 };
1041 let mut cache = self.cache.write().await;
1042 let result = crate::tools::ctx_preload::handle(
1043 &mut cache,
1044 &task,
1045 resolved_path.as_deref(),
1046 self.crp_mode,
1047 );
1048 drop(cache);
1049 self.record_call("ctx_preload", 0, 0, Some("preload".to_string()))
1050 .await;
1051 result
1052 }
1053 "ctx_wrapped" => {
1054 let period = get_str(args, "period").unwrap_or_else(|| "week".to_string());
1055 let result = crate::tools::ctx_wrapped::handle(&period);
1056 self.record_call("ctx_wrapped", 0, 0, Some(period)).await;
1057 result
1058 }
1059 "ctx_semantic_search" => {
1060 let query = get_str(args, "query")
1061 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
1062 let path = self
1063 .resolve_path(&get_str(args, "path").unwrap_or_else(|| ".".to_string()))
1064 .await;
1065 let top_k = get_int(args, "top_k").unwrap_or(10) as usize;
1066 let action = get_str(args, "action").unwrap_or_default();
1067 let mode = get_str(args, "mode");
1068 let languages = get_str_array(args, "languages");
1069 let path_glob = get_str(args, "path_glob");
1070 let result = if action == "reindex" {
1071 crate::tools::ctx_semantic_search::handle_reindex(&path)
1072 } else {
1073 crate::tools::ctx_semantic_search::handle(
1074 &query,
1075 &path,
1076 top_k,
1077 self.crp_mode,
1078 languages,
1079 path_glob.as_deref(),
1080 mode.as_deref(),
1081 )
1082 };
1083 self.record_call("ctx_semantic_search", 0, 0, Some("semantic".to_string()))
1084 .await;
1085 result
1086 }
1087 "ctx_execute" => {
1088 let action = get_str(args, "action").unwrap_or_default();
1089
1090 let result = if action == "batch" {
1091 let items_str = get_str(args, "items").ok_or_else(|| {
1092 ErrorData::invalid_params("items is required for batch", None)
1093 })?;
1094 let items: Vec<serde_json::Value> =
1095 serde_json::from_str(&items_str).map_err(|e| {
1096 ErrorData::invalid_params(format!("Invalid items JSON: {e}"), None)
1097 })?;
1098 let batch: Vec<(String, String)> = items
1099 .iter()
1100 .filter_map(|item| {
1101 let lang = item.get("language")?.as_str()?.to_string();
1102 let code = item.get("code")?.as_str()?.to_string();
1103 Some((lang, code))
1104 })
1105 .collect();
1106 crate::tools::ctx_execute::handle_batch(&batch)
1107 } else if action == "file" {
1108 let path = get_str(args, "path").ok_or_else(|| {
1109 ErrorData::invalid_params("path is required for action=file", None)
1110 })?;
1111 let intent = get_str(args, "intent");
1112 crate::tools::ctx_execute::handle_file(&path, intent.as_deref())
1113 } else {
1114 let language = get_str(args, "language")
1115 .ok_or_else(|| ErrorData::invalid_params("language is required", None))?;
1116 let code = get_str(args, "code")
1117 .ok_or_else(|| ErrorData::invalid_params("code is required", None))?;
1118 let intent = get_str(args, "intent");
1119 let timeout = get_int(args, "timeout").map(|t| t as u64);
1120 crate::tools::ctx_execute::handle(&language, &code, intent.as_deref(), timeout)
1121 };
1122
1123 self.record_call("ctx_execute", 0, 0, Some(action)).await;
1124 result
1125 }
1126 "ctx_symbol" => {
1127 let sym_name = get_str(args, "name")
1128 .ok_or_else(|| ErrorData::invalid_params("name is required", None))?;
1129 let file = get_str(args, "file");
1130 let kind = get_str(args, "kind");
1131 let session = self.session.read().await;
1132 let project_root = session
1133 .project_root
1134 .clone()
1135 .unwrap_or_else(|| ".".to_string());
1136 drop(session);
1137 let (result, original) = crate::tools::ctx_symbol::handle(
1138 &sym_name,
1139 file.as_deref(),
1140 kind.as_deref(),
1141 &project_root,
1142 );
1143 let sent = crate::core::tokens::count_tokens(&result);
1144 let saved = original.saturating_sub(sent);
1145 self.record_call("ctx_symbol", original, saved, kind).await;
1146 result
1147 }
1148 "ctx_graph_diagram" => {
1149 let file = get_str(args, "file");
1150 let depth = get_int(args, "depth").map(|d| d as usize);
1151 let kind = get_str(args, "kind");
1152 let session = self.session.read().await;
1153 let project_root = session
1154 .project_root
1155 .clone()
1156 .unwrap_or_else(|| ".".to_string());
1157 drop(session);
1158 let result = crate::tools::ctx_graph_diagram::handle(
1159 file.as_deref(),
1160 depth,
1161 kind.as_deref(),
1162 &project_root,
1163 );
1164 self.record_call("ctx_graph_diagram", 0, 0, kind).await;
1165 result
1166 }
1167 "ctx_routes" => {
1168 let method = get_str(args, "method");
1169 let path_prefix = get_str(args, "path");
1170 let session = self.session.read().await;
1171 let project_root = session
1172 .project_root
1173 .clone()
1174 .unwrap_or_else(|| ".".to_string());
1175 drop(session);
1176 let result = crate::tools::ctx_routes::handle(
1177 method.as_deref(),
1178 path_prefix.as_deref(),
1179 &project_root,
1180 );
1181 self.record_call("ctx_routes", 0, 0, None).await;
1182 result
1183 }
1184 "ctx_compress_memory" => {
1185 let path = self
1186 .resolve_path(
1187 &get_str(args, "path")
1188 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?,
1189 )
1190 .await;
1191 let result = crate::tools::ctx_compress_memory::handle(&path);
1192 self.record_call("ctx_compress_memory", 0, 0, None).await;
1193 result
1194 }
1195 "ctx_callers" => {
1196 let symbol = get_str(args, "symbol")
1197 .ok_or_else(|| ErrorData::invalid_params("symbol is required", None))?;
1198 let file = get_str(args, "file");
1199 let session = self.session.read().await;
1200 let project_root = session
1201 .project_root
1202 .clone()
1203 .unwrap_or_else(|| ".".to_string());
1204 drop(session);
1205 let result =
1206 crate::tools::ctx_callers::handle(&symbol, file.as_deref(), &project_root);
1207 self.record_call("ctx_callers", 0, 0, None).await;
1208 result
1209 }
1210 "ctx_callees" => {
1211 let symbol = get_str(args, "symbol")
1212 .ok_or_else(|| ErrorData::invalid_params("symbol is required", None))?;
1213 let file = get_str(args, "file");
1214 let session = self.session.read().await;
1215 let project_root = session
1216 .project_root
1217 .clone()
1218 .unwrap_or_else(|| ".".to_string());
1219 drop(session);
1220 let result =
1221 crate::tools::ctx_callees::handle(&symbol, file.as_deref(), &project_root);
1222 self.record_call("ctx_callees", 0, 0, None).await;
1223 result
1224 }
1225 "ctx_outline" => {
1226 let path = self
1227 .resolve_path(
1228 &get_str(args, "path")
1229 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?,
1230 )
1231 .await;
1232 let kind = get_str(args, "kind");
1233 let (result, original) = crate::tools::ctx_outline::handle(&path, kind.as_deref());
1234 let sent = crate::core::tokens::count_tokens(&result);
1235 let saved = original.saturating_sub(sent);
1236 self.record_call("ctx_outline", original, saved, kind).await;
1237 result
1238 }
1239 _ => {
1240 return Err(ErrorData::invalid_params(
1241 format!("Unknown tool: {name}"),
1242 None,
1243 ));
1244 }
1245 };
1246
1247 let mut result_text = result_text;
1248
1249 {
1250 let config = crate::core::config::Config::load();
1251 let density = crate::core::config::OutputDensity::effective(&config.output_density);
1252 result_text = crate::core::protocol::compress_output(&result_text, &density);
1253 }
1254
1255 if let Some(ctx) = auto_context {
1256 result_text = format!("{ctx}\n\n{result_text}");
1257 }
1258
1259 if let Some(warning) = throttle_warning {
1260 result_text = format!("{result_text}\n\n{warning}");
1261 }
1262
1263 if name == "ctx_read" {
1264 let read_path = self
1265 .resolve_path(&get_str(args, "path").unwrap_or_default())
1266 .await;
1267 let project_root = {
1268 let session = self.session.read().await;
1269 session.project_root.clone()
1270 };
1271 let mut cache = self.cache.write().await;
1272 let enrich = crate::tools::autonomy::enrich_after_read(
1273 &self.autonomy,
1274 &mut cache,
1275 &read_path,
1276 project_root.as_deref(),
1277 );
1278 if let Some(hint) = enrich.related_hint {
1279 result_text = format!("{result_text}\n{hint}");
1280 }
1281
1282 crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache);
1283 }
1284
1285 if name == "ctx_shell" {
1286 let cmd = get_str(args, "command").unwrap_or_default();
1287 let output_tokens = crate::core::tokens::count_tokens(&result_text);
1288 let calls = self.tool_calls.read().await;
1289 let last_original = calls.last().map(|c| c.original_tokens).unwrap_or(0);
1290 drop(calls);
1291 if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
1292 &self.autonomy,
1293 &cmd,
1294 last_original,
1295 output_tokens,
1296 ) {
1297 result_text = format!("{result_text}\n{hint}");
1298 }
1299 }
1300
1301 let skip_checkpoint = matches!(
1302 name,
1303 "ctx_compress"
1304 | "ctx_metrics"
1305 | "ctx_benchmark"
1306 | "ctx_analyze"
1307 | "ctx_cache"
1308 | "ctx_discover"
1309 | "ctx_dedup"
1310 | "ctx_session"
1311 | "ctx_knowledge"
1312 | "ctx_agent"
1313 | "ctx_share"
1314 | "ctx_wrapped"
1315 | "ctx_overview"
1316 | "ctx_preload"
1317 );
1318
1319 if !skip_checkpoint && self.increment_and_check() {
1320 if let Some(checkpoint) = self.auto_checkpoint().await {
1321 let combined = format!(
1322 "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
1323 self.checkpoint_interval
1324 );
1325 return Ok(CallToolResult::success(vec![Content::text(combined)]));
1326 }
1327 }
1328
1329 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1330 if tool_duration_ms > 100 {
1331 LeanCtxServer::append_tool_call_log(
1332 name,
1333 tool_duration_ms,
1334 0,
1335 0,
1336 None,
1337 &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1338 );
1339 }
1340
1341 let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1342 if current_count > 0 && current_count.is_multiple_of(100) {
1343 std::thread::spawn(crate::cloud_sync::cloud_background_tasks);
1344 }
1345
1346 Ok(CallToolResult::success(vec![Content::text(result_text)]))
1347 }
1348}
1349
1350pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1351 crate::instructions::build_instructions(crp_mode)
1352}
1353
1354fn get_str_array(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<Vec<String>> {
1355 let arr = args.as_ref()?.get(key)?.as_array()?;
1356 let mut out = Vec::with_capacity(arr.len());
1357 for v in arr {
1358 let s = v.as_str()?.to_string();
1359 out.push(s);
1360 }
1361 Some(out)
1362}
1363
1364fn get_str(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<String> {
1365 args.as_ref()?.get(key)?.as_str().map(|s| s.to_string())
1366}
1367
1368fn get_int(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<i64> {
1369 args.as_ref()?.get(key)?.as_i64()
1370}
1371
1372fn get_bool(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<bool> {
1373 args.as_ref()?.get(key)?.as_bool()
1374}
1375
1376fn extract_search_pattern_from_command(command: &str) -> Option<String> {
1377 let parts: Vec<&str> = command.split_whitespace().collect();
1378 if parts.len() < 2 {
1379 return None;
1380 }
1381 let cmd = parts[0];
1382 if cmd == "grep" || cmd == "rg" || cmd == "ag" || cmd == "ack" {
1383 for (i, part) in parts.iter().enumerate().skip(1) {
1384 if !part.starts_with('-') {
1385 return Some(part.to_string());
1386 }
1387 if (*part == "-e" || *part == "--regexp" || *part == "-m") && i + 1 < parts.len() {
1388 return Some(parts[i + 1].to_string());
1389 }
1390 }
1391 }
1392 if cmd == "find" || cmd == "fd" {
1393 for (i, part) in parts.iter().enumerate() {
1394 if (*part == "-name" || *part == "-iname") && i + 1 < parts.len() {
1395 return Some(
1396 parts[i + 1]
1397 .trim_matches('\'')
1398 .trim_matches('"')
1399 .to_string(),
1400 );
1401 }
1402 }
1403 if cmd == "fd" && parts.len() >= 2 && !parts[1].starts_with('-') {
1404 return Some(parts[1].to_string());
1405 }
1406 }
1407 None
1408}
1409
1410fn execute_command_in(command: &str, cwd: &str) -> (String, i32) {
1411 let (shell, flag) = crate::shell::shell_and_flag();
1412 let normalized_cmd = crate::tools::ctx_shell::normalize_command_for_shell(command);
1413 let dir = std::path::Path::new(cwd);
1414 let mut cmd = std::process::Command::new(&shell);
1415 cmd.arg(&flag)
1416 .arg(&normalized_cmd)
1417 .env("LEAN_CTX_ACTIVE", "1");
1418 if dir.is_dir() {
1419 cmd.current_dir(dir);
1420 }
1421 let output = cmd.output();
1422
1423 match output {
1424 Ok(out) => {
1425 let code = out.status.code().unwrap_or(1);
1426 let stdout = String::from_utf8_lossy(&out.stdout);
1427 let stderr = String::from_utf8_lossy(&out.stderr);
1428 let text = if stdout.is_empty() {
1429 stderr.to_string()
1430 } else if stderr.is_empty() {
1431 stdout.to_string()
1432 } else {
1433 format!("{stdout}\n{stderr}")
1434 };
1435 (text, code)
1436 }
1437 Err(e) => (format!("ERROR: {e}"), 1),
1438 }
1439}
1440
1441pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1442 crate::tool_defs::list_all_tool_defs()
1443 .into_iter()
1444 .map(|(name, desc, _)| (name, desc))
1445 .collect()
1446}
1447
1448pub fn tool_schemas_json_for_test() -> String {
1449 crate::tool_defs::list_all_tool_defs()
1450 .iter()
1451 .map(|(name, _, schema)| format!("{}: {}", name, schema))
1452 .collect::<Vec<_>>()
1453 .join("\n")
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 #[test]
1459 fn test_unified_tool_count() {
1460 let tools = crate::tool_defs::unified_tool_defs();
1461 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1462 }
1463
1464 #[test]
1465 fn test_granular_tool_count() {
1466 let tools = crate::tool_defs::granular_tool_defs();
1467 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1468 }
1469
1470 #[test]
1471 fn disabled_tools_filters_list() {
1472 let all = crate::tool_defs::granular_tool_defs();
1473 let total = all.len();
1474 let disabled = vec!["ctx_graph".to_string(), "ctx_agent".to_string()];
1475 let filtered: Vec<_> = all
1476 .into_iter()
1477 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1478 .collect();
1479 assert_eq!(filtered.len(), total - 2);
1480 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1481 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1482 }
1483
1484 #[test]
1485 fn empty_disabled_tools_returns_all() {
1486 let all = crate::tool_defs::granular_tool_defs();
1487 let total = all.len();
1488 let disabled: Vec<String> = vec![];
1489 let filtered: Vec<_> = all
1490 .into_iter()
1491 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1492 .collect();
1493 assert_eq!(filtered.len(), total);
1494 }
1495
1496 #[test]
1497 fn misspelled_disabled_tool_is_silently_ignored() {
1498 let all = crate::tool_defs::granular_tool_defs();
1499 let total = all.len();
1500 let disabled = vec!["ctx_nonexistent_tool".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);
1506 }
1507}