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