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