Skip to main content

lean_ctx/
server.rs

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