Skip to main content

lean_ctx/mcp_server/
mod.rs

1mod dispatch;
2mod execute;
3pub mod helpers;
4
5use std::collections::BTreeMap;
6use std::sync::Arc;
7
8use rmcp::handler::server::ServerHandler;
9use rmcp::model::*;
10use rmcp::service::{RequestContext, RoleServer};
11use rmcp::ErrorData;
12
13use crate::tools::{CrpMode, LeanCtxServer};
14
15/// Tools that ONLY exist on the cloud server. No local fallback — return an error when offline.
16pub const CLOUD_ONLY_TOOLS: &[&str] = &[
17    "ctx_brain",
18    "ctx_routes",
19    "ctx_gain",
20    "ctx_cost",
21    "ctx_heatmap",
22    "ctx_stats",
23];
24
25/// Tools that prefer cloud routing but fall back to local file storage when not configured.
26const CLOUD_PREFERRED_TOOLS: &[&str] = &["ctx_knowledge", "ctx_session"];
27
28/// Outcome of attempting to route a tool call to the cloud server.
29enum CloudResult {
30    /// Server responded successfully; return this text to the caller.
31    Success(String),
32    /// No cloud connection is configured; caller should fall back to local handling.
33    NotConfigured,
34    /// Connection is configured but the call failed; return this error text to the caller.
35    Error(String),
36}
37
38impl ServerHandler for LeanCtxServer {
39    fn get_info(&self) -> ServerInfo {
40        let capabilities = ServerCapabilities::builder().enable_tools().build();
41
42        let instructions = crate::instructions::build_instructions(self.crp_mode);
43
44        InitializeResult::new(capabilities)
45            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
46            .with_instructions(instructions)
47    }
48
49    async fn initialize(
50        &self,
51        request: InitializeRequestParams,
52        _context: RequestContext<RoleServer>,
53    ) -> Result<InitializeResult, ErrorData> {
54        let name = request.client_info.name.clone();
55        tracing::info!("MCP client connected: {:?}", name);
56        *self.client_name.write().await = name.clone();
57
58        let derived_root = derive_project_root_from_cwd();
59        let cwd_str = std::env::current_dir()
60            .ok()
61            .map(|p| p.to_string_lossy().to_string())
62            .unwrap_or_default();
63        {
64            let mut session = self.session.write().await;
65            if !cwd_str.is_empty() {
66                session.shell_cwd = Some(cwd_str.clone());
67            }
68            if let Some(ref root) = derived_root {
69                session.project_root = Some(root.clone());
70                tracing::info!("Project root set to: {root}");
71            } else if let Some(ref root) = session.project_root {
72                let root_path = std::path::Path::new(root);
73                let root_has_marker = has_project_marker(root_path);
74                let root_str = root_path.to_string_lossy();
75                let root_suspicious = root_str.contains("/.claude")
76                    || root_str.contains("/.codex")
77                    || root_str.contains("/var/folders/")
78                    || root_str.contains("/tmp/")
79                    || root_str.contains("\\.claude")
80                    || root_str.contains("\\.codex")
81                    || root_str.contains("\\AppData\\Local\\Temp")
82                    || root_str.contains("\\Temp\\");
83                if root_suspicious && !root_has_marker {
84                    session.project_root = None;
85                }
86            }
87            let _ = session.save();
88        }
89
90        let agent_name = name.clone();
91        let agent_root = derived_root.clone().unwrap_or_default();
92        let agent_id_handle = self.agent_id.clone();
93        tokio::task::spawn_blocking(move || {
94            if std::env::var("NEBU_CTX_HEADLESS").is_ok() {
95                return;
96            }
97            if let Some(home) = dirs::home_dir() {
98                let _ = crate::rules_inject::inject_all_rules(&home);
99            }
100            crate::hooks::refresh_installed_hooks();
101            crate::core::version_check::check_background();
102
103            if !agent_root.is_empty() {
104                let role = match agent_name.to_lowercase().as_str() {
105                    n if n.contains("cursor") => Some("coder"),
106                    n if n.contains("claude") => Some("coder"),
107                    n if n.contains("codex") => Some("coder"),
108                    n if n.contains("antigravity") || n.contains("gemini") => Some("explorer"),
109                    n if n.contains("review") => Some("reviewer"),
110                    n if n.contains("test") => Some("tester"),
111                    _ => None,
112                };
113                let env_role = std::env::var("NEBU_CTX_AGENT_ROLE").ok();
114                let effective_role = env_role.as_deref().or(role);
115                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
116                registry.cleanup_stale(24);
117                let id = registry.register("mcp", effective_role, &agent_root);
118                let _ = registry.save();
119                if let Ok(mut guard) = agent_id_handle.try_write() {
120                    *guard = Some(id);
121                }
122            }
123        });
124
125        let instructions =
126            crate::instructions::build_instructions_with_client(self.crp_mode, &name);
127        let capabilities = ServerCapabilities::builder().enable_tools().build();
128
129        Ok(InitializeResult::new(capabilities)
130            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
131            .with_instructions(instructions))
132    }
133
134    async fn list_tools(
135        &self,
136        _request: Option<PaginatedRequestParams>,
137        _context: RequestContext<RoleServer>,
138    ) -> Result<ListToolsResult, ErrorData> {
139        let all_tools = if crate::tool_defs::is_lazy_mode() {
140            crate::tool_defs::lazy_tool_defs()
141        } else if std::env::var("NEBU_CTX_UNIFIED").is_ok()
142            && std::env::var("NEBU_CTX_FULL_TOOLS").is_err()
143        {
144            crate::tool_defs::unified_tool_defs()
145        } else {
146            crate::tool_defs::granular_tool_defs()
147        };
148
149        let disabled = crate::core::config::Config::load().disabled_tools_effective();
150        let tools = if disabled.is_empty() {
151            all_tools
152        } else {
153            all_tools
154                .into_iter()
155                .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
156                .collect()
157        };
158
159        let tools = {
160            let active = self.workflow.read().await.clone();
161            if let Some(run) = active {
162                if let Some(state) = run.spec.state(&run.current) {
163                    if let Some(allowed) = &state.allowed_tools {
164                        let mut allow: std::collections::HashSet<&str> =
165                            allowed.iter().map(|s| s.as_str()).collect();
166                        allow.insert("ctx");
167                        allow.insert("ctx_workflow");
168                        return Ok(ListToolsResult {
169                            tools: tools
170                                .into_iter()
171                                .filter(|t| allow.contains(t.name.as_ref()))
172                                .collect(),
173                            ..Default::default()
174                        });
175                    }
176                }
177            }
178            tools
179        };
180
181        let tools = merge_remote_tool_defs(tools).await;
182
183        Ok(ListToolsResult {
184            tools,
185            ..Default::default()
186        })
187    }
188
189    async fn call_tool(
190        &self,
191        request: CallToolRequestParams,
192        _context: RequestContext<RoleServer>,
193    ) -> Result<CallToolResult, ErrorData> {
194        self.check_idle_expiry().await;
195
196        let original_name = request.name.as_ref().to_string();
197        let (resolved_name, resolved_args) = if original_name == "ctx" {
198            let sub = request
199                .arguments
200                .as_ref()
201                .and_then(|a| a.get("tool"))
202                .and_then(|v| v.as_str())
203                .map(|s| s.to_string())
204                .ok_or_else(|| {
205                    ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
206                })?;
207            let tool_name = if sub.starts_with("ctx_") {
208                sub
209            } else {
210                format!("ctx_{sub}")
211            };
212            let mut args = request.arguments.unwrap_or_default();
213            args.remove("tool");
214            (tool_name, Some(args))
215        } else {
216            (original_name, request.arguments)
217        };
218        let name = resolved_name.as_str();
219        let args = &resolved_args;
220
221        // Route cloud-only and cloud-preferred tools through the cloud server.
222        let cloud_fallback_warning = if CLOUD_ONLY_TOOLS.contains(&name) {
223            match route_to_cloud(name, args).await {
224                CloudResult::Success(s) => {
225                    // Record telemetry so dashboard reflects cloud tool usage.
226                    self.record_call(name, 0, 0, None).await;
227                    return Ok(CallToolResult::success(vec![Content::text(s)]));
228                }
229                CloudResult::NotConfigured => {
230                    let msg = format!(
231                        "{name} requires a cloud connection. Run: nebu-ctx cloud connect"
232                    );
233                    return Ok(CallToolResult::success(vec![Content::text(msg)]));
234                }
235                CloudResult::Error(e) => {
236                    return Ok(CallToolResult::success(vec![Content::text(e)]));
237                }
238            }
239        } else if CLOUD_PREFERRED_TOOLS.contains(&name) {
240            // Task A: when cloud is configured, fail hard rather than silently
241            // writing to local JSON. Local fallback only applies when no cloud
242            // server has been set up at all.
243            let cloud_is_configured = crate::cloud_client::ServerClient::load().is_ok();
244            match route_to_cloud(name, args).await {
245                CloudResult::Success(s) => {
246                    // Record telemetry so dashboard reflects cloud tool usage.
247                    self.record_call(name, 0, 0, None).await;
248                    return Ok(CallToolResult::success(vec![Content::text(s)]));
249                }
250                CloudResult::NotConfigured if cloud_is_configured => {
251                    // Config exists but load/connect failed transiently — fail hard.
252                    return Ok(CallToolResult::success(vec![Content::text(format!(
253                        "{name}: cloud server is configured but unreachable. Check: nebu-ctx cloud status"
254                    ))]));
255                }
256                CloudResult::NotConfigured => Some(
257                    "\n\n⚠ Running locally (no cloud connection). Data stored in .nebu-ctx/ only.\n  To enable cloud persistence: nebu-ctx cloud connect"
258                        .to_string(),
259                ),
260                CloudResult::Error(e) => return Ok(CallToolResult::success(vec![Content::text(e)])),
261            }
262        } else {
263            None
264        };
265
266        if name != "ctx_workflow" {
267            let active = self.workflow.read().await.clone();
268            if let Some(run) = active {
269                if let Some(state) = run.spec.state(&run.current) {
270                    if let Some(allowed) = &state.allowed_tools {
271                        let allowed_ok = allowed.iter().any(|t| t == name) || name == "ctx";
272                        if !allowed_ok {
273                            let mut shown = allowed.clone();
274                            shown.sort();
275                            shown.truncate(30);
276                            return Ok(CallToolResult::success(vec![Content::text(format!(
277                                "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed ({} shown): {}",
278                                run.spec.name,
279                                run.current,
280                                shown.len(),
281                                shown.join(", ")
282                            ))]));
283                        }
284                    }
285                }
286            }
287        }
288
289        let auto_context = {
290            let task = {
291                let session = self.session.read().await;
292                session.task.as_ref().map(|t| t.description.clone())
293            };
294            let project_root = {
295                let session = self.session.read().await;
296                session.project_root.clone()
297            };
298            let mut cache = self.cache.write().await;
299            crate::tools::autonomy::session_lifecycle_pre_hook(
300                &self.autonomy,
301                name,
302                &mut cache,
303                task.as_deref(),
304                project_root.as_deref(),
305                self.crp_mode,
306            )
307        };
308
309        let throttle_result = {
310            let fp = args
311                .as_ref()
312                .map(|a| {
313                    crate::core::loop_detection::LoopDetector::fingerprint(
314                        &serde_json::Value::Object(a.clone()),
315                    )
316                })
317                .unwrap_or_default();
318            let mut detector = self.loop_detector.write().await;
319
320            let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
321            let is_search_shell = name == "ctx_shell" && {
322                let cmd = args
323                    .as_ref()
324                    .and_then(|a| a.get("command"))
325                    .and_then(|v| v.as_str())
326                    .unwrap_or("");
327                crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
328            };
329
330            if is_search || is_search_shell {
331                let search_pattern = args.as_ref().and_then(|a| {
332                    a.get("pattern")
333                        .or_else(|| a.get("query"))
334                        .and_then(|v| v.as_str())
335                });
336                let shell_pattern = if is_search_shell {
337                    args.as_ref()
338                        .and_then(|a| a.get("command"))
339                        .and_then(|v| v.as_str())
340                        .and_then(helpers::extract_search_pattern_from_command)
341                } else {
342                    None
343                };
344                let pat = search_pattern.or(shell_pattern.as_deref());
345                detector.record_search(name, &fp, pat)
346            } else {
347                detector.record_call(name, &fp)
348            }
349        };
350
351        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
352            let msg = throttle_result.message.unwrap_or_default();
353            return Ok(CallToolResult::success(vec![Content::text(msg)]));
354        }
355
356        let throttle_warning =
357            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
358                throttle_result.message.clone()
359            } else {
360                None
361            };
362
363        let tool_start = std::time::Instant::now();
364        let result_text = self.dispatch_tool(name, args).await?;
365
366        let mut result_text = result_text;
367
368        // Archive large tool outputs before density compression (zero-loss recovery)
369        let archive_hint = {
370            use crate::core::archive;
371            let archivable = matches!(
372                name,
373                "ctx_shell"
374                    | "ctx_read"
375                    | "ctx_multi_read"
376                    | "ctx_smart_read"
377                    | "ctx_execute"
378                    | "ctx_search"
379                    | "ctx_tree"
380            );
381            if archivable && archive::should_archive(&result_text) {
382                let cmd = helpers::get_str(args, "command")
383                    .or_else(|| helpers::get_str(args, "path"))
384                    .unwrap_or_default();
385                let session_id = self.session.read().await.id.clone();
386                let tokens = crate::core::tokens::count_tokens(&result_text);
387                archive::store(name, &cmd, &result_text, Some(&session_id))
388                    .map(|id| archive::format_hint(&id, result_text.len(), tokens))
389            } else {
390                None
391            }
392        };
393
394        {
395            let config = crate::core::config::Config::load();
396            let density = crate::core::config::OutputDensity::effective(&config.output_density);
397            result_text = crate::core::protocol::compress_output(&result_text, &density);
398        }
399
400        if let Some(hint) = archive_hint {
401            result_text = format!("{result_text}\n{hint}");
402        }
403
404        if let Some(ctx) = auto_context {
405            result_text = format!("{ctx}\n\n{result_text}");
406        }
407
408        if let Some(warning) = throttle_warning {
409            result_text = format!("{result_text}\n\n{warning}");
410        }
411
412        if let Some(offline_note) = cloud_fallback_warning {
413            result_text = format!("{result_text}{offline_note}");
414        }
415
416        if name == "ctx_read" {
417            let read_path = self
418                .resolve_path_or_passthrough(&helpers::get_str(args, "path").unwrap_or_default())
419                .await;
420            let project_root = {
421                let session = self.session.read().await;
422                session.project_root.clone()
423            };
424            let mut cache = self.cache.write().await;
425            let enrich = crate::tools::autonomy::enrich_after_read(
426                &self.autonomy,
427                &mut cache,
428                &read_path,
429                project_root.as_deref(),
430            );
431            if let Some(hint) = enrich.related_hint {
432                result_text = format!("{result_text}\n{hint}");
433            }
434
435            crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache);
436        }
437
438        if name == "ctx_shell" {
439            let cmd = helpers::get_str(args, "command").unwrap_or_default();
440            let output_tokens = crate::core::tokens::count_tokens(&result_text);
441            let calls = self.tool_calls.read().await;
442            let last_original = calls.last().map(|c| c.original_tokens).unwrap_or(0);
443            drop(calls);
444            if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
445                &self.autonomy,
446                &cmd,
447                last_original,
448                output_tokens,
449            ) {
450                result_text = format!("{result_text}\n{hint}");
451            }
452        }
453
454        {
455            let input = helpers::canonical_args_string(args);
456            let input_md5 = helpers::md5_hex(&input);
457            let output_md5 = helpers::md5_hex(&result_text);
458            let action = helpers::get_str(args, "action");
459            let agent_id = self.agent_id.read().await.clone();
460            let client_name = self.client_name.read().await.clone();
461            let mut explicit_intent: Option<(
462                crate::core::intent_protocol::IntentRecord,
463                Option<String>,
464                String,
465            )> = None;
466
467            {
468                let empty_args = serde_json::Map::new();
469                let args_map = args.as_ref().unwrap_or(&empty_args);
470                let mut session = self.session.write().await;
471                session.record_tool_receipt(
472                    name,
473                    action.as_deref(),
474                    &input_md5,
475                    &output_md5,
476                    agent_id.as_deref(),
477                    Some(&client_name),
478                );
479
480                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
481                    name,
482                    action.as_deref(),
483                    args_map,
484                    session.project_root.as_deref(),
485                ) {
486                    let is_explicit =
487                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
488                    let root = session.project_root.clone();
489                    let sid = session.id.clone();
490                    session.record_intent(intent.clone());
491                    if is_explicit {
492                        explicit_intent = Some((intent, root, sid));
493                    }
494                }
495                if session.should_save() {
496                    let _ = session.save();
497                }
498            }
499
500            if let Some((intent, root, session_id)) = explicit_intent {
501                crate::core::intent_protocol::apply_side_effects(
502                    &intent,
503                    root.as_deref(),
504                    &session_id,
505                );
506            }
507
508            // Autopilot: consolidation loop (silent, deterministic, budgeted).
509            if self.autonomy.is_enabled() {
510                let (calls, project_root) = {
511                    let session = self.session.read().await;
512                    (session.stats.total_tool_calls, session.project_root.clone())
513                };
514
515                if let Some(root) = project_root {
516                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
517                        let root_clone = root.clone();
518                        tokio::task::spawn_blocking(move || {
519                            if crate::core::consolidation_engine::consolidate_latest(
520                                &root_clone,
521                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
522                            ).is_ok() {
523                                // Task B: forward promoted facts to PostgreSQL.
524                                crate::cloud_client::post_knowledge_to_cloud(&root_clone);
525                            }
526                        });
527                    }
528                }
529            }
530
531            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
532            let input_tokens = crate::core::tokens::count_tokens(&input) as u64;
533            let output_tokens = crate::core::tokens::count_tokens(&result_text) as u64;
534            let mut store = crate::core::a2a::cost_attribution::CostStore::load();
535            store.record_tool_call(&agent_key, &client_name, name, input_tokens, output_tokens);
536            let _ = store.save();
537        }
538
539        let skip_checkpoint = matches!(
540            name,
541            "ctx_compress"
542                | "ctx_metrics"
543                | "ctx_benchmark"
544                | "ctx_analyze"
545                | "ctx_cache"
546                | "ctx_discover"
547                | "ctx_dedup"
548                | "ctx_session"
549                | "ctx_knowledge"
550                | "ctx_agent"
551                | "ctx_share"
552                | "ctx_wrapped"
553                | "ctx_overview"
554                | "ctx_preload"
555                | "ctx_cost"
556                | "ctx_gain"
557                | "ctx_heatmap"
558                | "ctx_stats"
559                | "ctx_task"
560                | "ctx_impact"
561                | "ctx_architecture"
562                | "ctx_workflow"
563        );
564
565        if !skip_checkpoint && self.increment_and_check() {
566            if let Some(checkpoint) = self.auto_checkpoint().await {
567                let combined = format!(
568                    "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
569                    self.checkpoint_interval
570                );
571                return Ok(CallToolResult::success(vec![Content::text(combined)]));
572            }
573        }
574
575        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
576        if tool_duration_ms > 100 {
577            LeanCtxServer::append_tool_call_log(
578                name,
579                tool_duration_ms,
580                0,
581                0,
582                None,
583                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
584            );
585        }
586
587        Ok(CallToolResult::success(vec![Content::text(result_text)]))
588    }
589}
590
591/// Routes a tool call to the cloud server, enriching it with the current git context.
592///
593/// Returns [`CloudResult::Success`] when the server responds, [`CloudResult::NotConfigured`] when
594/// no connection is saved, and [`CloudResult::Error`] when the connection exists but the call fails.
595async fn route_to_cloud(name: &str, args: &Option<serde_json::Map<String, serde_json::Value>>) -> CloudResult {
596    let tool_name = name.to_string();
597    let arguments = args.clone().unwrap_or_default();
598
599    let result = tokio::task::spawn_blocking(move || {
600        let client = match crate::cloud_client::ServerClient::load() {
601            Ok(c) => c,
602            Err(_) => return CloudResult::NotConfigured,
603        };
604        let current_directory = match std::env::current_dir() {
605            Ok(d) => d,
606            Err(e) => return CloudResult::Error(format!("Could not determine working directory: {e}")),
607        };
608        let project_context = crate::git_context::discover_project_context(&current_directory);
609        match client.call_tool(&tool_name, arguments, &project_context) {
610            Ok(value) => {
611                let text = match &value {
612                    serde_json::Value::String(s) => s.clone(),
613                    other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
614                };
615                CloudResult::Success(text)
616            }
617            Err(e) => CloudResult::Error(format!(
618                "Cloud tool {tool_name} failed: {e}\nCheck connection: nebu-ctx cloud status"
619            )),
620        }
621    })
622    .await;
623
624    result.unwrap_or_else(|_| CloudResult::Error("Cloud routing task panicked".to_string()))
625}
626
627/// Merges tool definitions fetched from the cloud server into the local manifest.
628///
629/// Only tool names listed in [`CLOUD_ONLY_TOOLS`] are pulled from the remote manifest, so the
630/// local definitions always win for [`CLOUD_PREFERRED_TOOLS`]. When the server is unreachable the
631/// local manifest is returned unchanged.
632async fn merge_remote_tool_defs(local_tools: Vec<Tool>) -> Vec<Tool> {
633    let Some(remote_tools) = load_cloud_only_tool_defs().await else {
634        return local_tools;
635    };
636
637    let mut merged = BTreeMap::new();
638    for tool in local_tools {
639        merged.insert(tool.name.to_string(), tool);
640    }
641    for tool in remote_tools {
642        merged.insert(tool.name.to_string(), tool);
643    }
644    merged.into_values().collect()
645}
646
647/// Fetches tool definitions for [`CLOUD_ONLY_TOOLS`] from the configured cloud server.
648async fn load_cloud_only_tool_defs() -> Option<Vec<Tool>> {
649    tokio::task::spawn_blocking(|| {
650        let client = crate::cloud_client::ServerClient::load().ok()?;
651        let remote = client.list_tools().ok()?;
652        Some(
653            remote
654                .tools
655                .into_iter()
656                .filter(|tool| CLOUD_ONLY_TOOLS.contains(&tool.name.as_str()))
657                .map(|tool| {
658                    let input_schema = match tool.input_schema {
659                        serde_json::Value::Object(map) => map,
660                        _ => serde_json::Map::new(),
661                    };
662                    Tool::new(tool.name, tool.description, Arc::new(input_schema))
663                })
664                .collect::<Vec<_>>(),
665        )
666    })
667    .await
668    .ok()
669    .flatten()
670}
671
672pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
673    crate::instructions::build_instructions(crp_mode)
674}
675
676pub fn build_claude_code_instructions_for_test() -> String {
677    crate::instructions::claude_code_instructions()
678}
679
680const PROJECT_MARKERS: &[&str] = &[
681    ".git",
682    "Cargo.toml",
683    "package.json",
684    "go.mod",
685    "pyproject.toml",
686    "setup.py",
687    "pom.xml",
688    "build.gradle",
689    "Makefile",
690    ".lean-ctx.toml",
691];
692
693fn has_project_marker(dir: &std::path::Path) -> bool {
694    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
695}
696
697fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
698    if let Some(home) = dirs::home_dir() {
699        if dir == home {
700            return true;
701        }
702    }
703    let dir_str = dir.to_string_lossy();
704    dir_str.ends_with("/.claude")
705        || dir_str.ends_with("/.codex")
706        || dir_str.contains("/.claude/")
707        || dir_str.contains("/.codex/")
708}
709
710fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
711    std::process::Command::new("git")
712        .args(["rev-parse", "--show-toplevel"])
713        .current_dir(dir)
714        .stdout(std::process::Stdio::piped())
715        .stderr(std::process::Stdio::null())
716        .output()
717        .ok()
718        .and_then(|o| {
719            if o.status.success() {
720                String::from_utf8(o.stdout)
721                    .ok()
722                    .map(|s| s.trim().to_string())
723            } else {
724                None
725            }
726        })
727}
728
729pub fn derive_project_root_from_cwd() -> Option<String> {
730    let cwd = std::env::current_dir().ok()?;
731    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
732
733    if is_home_or_agent_dir(&canonical) {
734        return git_toplevel_from(&canonical);
735    }
736
737    if has_project_marker(&canonical) {
738        return Some(canonical.to_string_lossy().to_string());
739    }
740
741    if let Some(git_root) = git_toplevel_from(&canonical) {
742        return Some(git_root);
743    }
744
745    None
746}
747
748pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
749    crate::tool_defs::list_all_tool_defs()
750        .into_iter()
751        .map(|(name, desc, _)| (name, desc))
752        .collect()
753}
754
755pub fn tool_schemas_json_for_test() -> String {
756    crate::tool_defs::list_all_tool_defs()
757        .iter()
758        .map(|(name, _, schema)| format!("{}: {}", name, schema))
759        .collect::<Vec<_>>()
760        .join("\n")
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766
767    #[test]
768    fn project_markers_detected() {
769        let tmp = tempfile::tempdir().unwrap();
770        let root = tmp.path().join("myproject");
771        std::fs::create_dir_all(&root).unwrap();
772        assert!(!has_project_marker(&root));
773
774        std::fs::create_dir(root.join(".git")).unwrap();
775        assert!(has_project_marker(&root));
776    }
777
778    #[test]
779    fn home_dir_detected_as_agent_dir() {
780        if let Some(home) = dirs::home_dir() {
781            assert!(is_home_or_agent_dir(&home));
782        }
783    }
784
785    #[test]
786    fn agent_dirs_detected() {
787        let claude = std::path::PathBuf::from("/home/user/.claude");
788        assert!(is_home_or_agent_dir(&claude));
789        let codex = std::path::PathBuf::from("/home/user/.codex");
790        assert!(is_home_or_agent_dir(&codex));
791        let project = std::path::PathBuf::from("/home/user/projects/myapp");
792        assert!(!is_home_or_agent_dir(&project));
793    }
794
795    #[test]
796    fn test_unified_tool_count() {
797        let tools = crate::tool_defs::unified_tool_defs();
798        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
799    }
800
801    #[test]
802    fn test_granular_tool_count() {
803        let tools = crate::tool_defs::granular_tool_defs();
804        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
805    }
806
807    #[test]
808    fn disabled_tools_filters_list() {
809        let all = crate::tool_defs::granular_tool_defs();
810        let total = all.len();
811        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
812        let filtered: Vec<_> = all
813            .into_iter()
814            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
815            .collect();
816        assert_eq!(filtered.len(), total - 2);
817        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
818        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
819    }
820
821    #[test]
822    fn empty_disabled_tools_returns_all() {
823        let all = crate::tool_defs::granular_tool_defs();
824        let total = all.len();
825        let disabled: Vec<String> = vec![];
826        let filtered: Vec<_> = all
827            .into_iter()
828            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
829            .collect();
830        assert_eq!(filtered.len(), total);
831    }
832
833    #[test]
834    fn misspelled_disabled_tool_is_silently_ignored() {
835        let all = crate::tool_defs::granular_tool_defs();
836        let total = all.len();
837        let disabled = ["ctx_nonexistent_tool".to_string()];
838        let filtered: Vec<_> = all
839            .into_iter()
840            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
841            .collect();
842        assert_eq!(filtered.len(), total);
843    }
844
845    #[test]
846    fn cloud_tool_constants_are_disjoint() {
847        for name in CLOUD_ONLY_TOOLS {
848            assert!(
849                !CLOUD_PREFERRED_TOOLS.contains(name),
850                "{name} appears in both CLOUD_ONLY_TOOLS and CLOUD_PREFERRED_TOOLS"
851            );
852        }
853    }
854
855    #[test]
856    fn ctx_brain_is_cloud_only_not_preferred() {
857        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_brain"));
858        assert!(!CLOUD_PREFERRED_TOOLS.contains(&"ctx_brain"));
859    }
860
861    #[test]
862    fn ctx_knowledge_and_ctx_session_are_cloud_preferred() {
863        assert!(CLOUD_PREFERRED_TOOLS.contains(&"ctx_knowledge"));
864        assert!(CLOUD_PREFERRED_TOOLS.contains(&"ctx_session"));
865        assert!(!CLOUD_ONLY_TOOLS.contains(&"ctx_knowledge"));
866        assert!(!CLOUD_ONLY_TOOLS.contains(&"ctx_session"));
867    }
868
869    #[test]
870    fn analytics_tools_are_cloud_only() {
871        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_gain"));
872        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_cost"));
873        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_heatmap"));
874        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_stats"));
875    }
876
877    #[test]
878    fn ctx_brain_in_granular_tool_defs() {
879        let tools = crate::tool_defs::granular_tool_defs();
880        assert!(
881            tools.iter().any(|t| t.name.as_ref() == "ctx_brain"),
882            "ctx_brain must appear in the local manifest so Claude sees it even when offline"
883        );
884    }
885
886    #[test]
887    fn ctx_brain_stub_has_required_actions() {
888        let tools = crate::tool_defs::granular_tool_defs();
889        let brain = tools.iter().find(|t| t.name.as_ref() == "ctx_brain").unwrap();
890        let schema = serde_json::to_string(&*brain.input_schema).unwrap();
891        assert!(schema.contains("store"), "ctx_brain schema must include 'store' action");
892        assert!(schema.contains("recall"), "ctx_brain schema must include 'recall' action");
893        assert!(schema.contains("forget"), "ctx_brain schema must include 'forget' action");
894    }
895
896    #[test]
897    fn route_to_cloud_returns_not_configured_or_error_when_no_connection() {
898        // Without a saved connection, route_to_cloud must not panic. It should return
899        // NotConfigured or Error (never Success unless CI has a live server).
900        let rt = tokio::runtime::Builder::new_current_thread()
901            .enable_all()
902            .build()
903            .unwrap();
904        let result = rt.block_on(route_to_cloud("ctx_brain", &None));
905        match result {
906            CloudResult::Success(_) => {} // acceptable if CI has a live server
907            CloudResult::NotConfigured => {}
908            CloudResult::Error(_) => {}
909        }
910    }
911}