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