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