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