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