Skip to main content

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