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