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