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