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 configured server. No local fallback when offline.
16pub const SERVER_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 server routing but fall back to local file storage when not configured.
26const SERVER_PREFERRED_TOOLS: &[&str] = &["ctx_knowledge", "ctx_session"];
27
28/// Outcome of attempting to route a tool call to the server.
29enum ServerRoutingResult {
30    /// Server responded successfully; return this text to the caller.
31    Success(String),
32    /// No server 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 server-only and server-preferred tools through the configured server.
222        let server_fallback_warning = if SERVER_ONLY_TOOLS.contains(&name) {
223            match route_to_server(name, args).await {
224                ServerRoutingResult::Success(s) => {
225                    // Record telemetry so the dashboard reflects hosted tool usage.
226                    self.record_call(name, 0, 0, None).await;
227                    return Ok(CallToolResult::success(vec![Content::text(s)]));
228                }
229                ServerRoutingResult::NotConfigured => {
230                    let msg = format!("{name} requires a server connection. Run: nebu-ctx connect");
231                    return Ok(CallToolResult::success(vec![Content::text(msg)]));
232                }
233                ServerRoutingResult::Error(e) => {
234                    return Ok(CallToolResult::success(vec![Content::text(e)]));
235                }
236            }
237        } else if SERVER_PREFERRED_TOOLS.contains(&name) {
238            // When a server is configured, fail hard rather than silently
239            // writing to local JSON. Local fallback only applies when no server
240            // server has been set up at all.
241            let server_is_configured = crate::server_client::ServerClient::load().is_ok();
242            match route_to_server(name, args).await {
243                ServerRoutingResult::Success(s) => {
244                    // Record telemetry so the dashboard reflects hosted tool usage.
245                    self.record_call(name, 0, 0, None).await;
246                    return Ok(CallToolResult::success(vec![Content::text(s)]));
247                }
248                ServerRoutingResult::NotConfigured if server_is_configured => {
249                    // Config exists but load/connect failed transiently — fail hard.
250                    return Ok(CallToolResult::success(vec![Content::text(format!(
251                        "{name}: server is configured but unreachable. Check: nebu-ctx status"
252                    ))]));
253                }
254                ServerRoutingResult::NotConfigured => Some(
255                    "\n\n⚠ Running locally (no server connection). Data stored in .nebu-ctx/ only.\n  To enable hosted persistence: nebu-ctx connect"
256                        .to_string(),
257                ),
258                ServerRoutingResult::Error(e) => {
259                    return Ok(CallToolResult::success(vec![Content::text(e)]))
260                }
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) = server_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                                crate::server_client::post_knowledge_to_server(&root_clone);
524                            }
525                        });
526                    }
527                }
528            }
529
530            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
531            let input_tokens = crate::core::tokens::count_tokens(&input) as u64;
532            let output_tokens = crate::core::tokens::count_tokens(&result_text) as u64;
533            let mut store = crate::core::a2a::cost_attribution::CostStore::load();
534            store.record_tool_call(&agent_key, &client_name, name, input_tokens, output_tokens);
535            let _ = store.save();
536        }
537
538        let skip_checkpoint = matches!(
539            name,
540            "ctx_compress"
541                | "ctx_metrics"
542                | "ctx_benchmark"
543                | "ctx_analyze"
544                | "ctx_cache"
545                | "ctx_discover"
546                | "ctx_dedup"
547                | "ctx_session"
548                | "ctx_knowledge"
549                | "ctx_agent"
550                | "ctx_share"
551                | "ctx_wrapped"
552                | "ctx_overview"
553                | "ctx_preload"
554                | "ctx_cost"
555                | "ctx_gain"
556                | "ctx_heatmap"
557                | "ctx_stats"
558                | "ctx_task"
559                | "ctx_impact"
560                | "ctx_architecture"
561                | "ctx_workflow"
562        );
563
564        if !skip_checkpoint && self.increment_and_check() {
565            if let Some(checkpoint) = self.auto_checkpoint().await {
566                let combined = format!(
567                    "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
568                    self.checkpoint_interval
569                );
570                return Ok(CallToolResult::success(vec![Content::text(combined)]));
571            }
572        }
573
574        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
575        if tool_duration_ms > 100 {
576            LeanCtxServer::append_tool_call_log(
577                name,
578                tool_duration_ms,
579                0,
580                0,
581                None,
582                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
583            );
584        }
585
586        Ok(CallToolResult::success(vec![Content::text(result_text)]))
587    }
588}
589
590/// Routes a tool call to the configured server, enriching it with the current git context.
591///
592/// Returns [`ServerRoutingResult::Success`] when the server responds,
593/// [`ServerRoutingResult::NotConfigured`] when no connection is saved, and
594/// [`ServerRoutingResult::Error`] when the connection exists but the call fails.
595async fn route_to_server(
596    name: &str,
597    args: &Option<serde_json::Map<String, serde_json::Value>>,
598) -> ServerRoutingResult {
599    let tool_name = name.to_string();
600    let arguments = args.clone().unwrap_or_default();
601
602    let result = tokio::task::spawn_blocking(move || {
603        let client = match crate::server_client::ServerClient::load() {
604            Ok(c) => c,
605            Err(_) => return ServerRoutingResult::NotConfigured,
606        };
607        let current_directory = match std::env::current_dir() {
608            Ok(d) => d,
609            Err(e) => {
610                return ServerRoutingResult::Error(format!(
611                    "Could not determine working directory: {e}"
612                ))
613            }
614        };
615        let project_context = crate::git_context::discover_project_context(&current_directory);
616        match client.call_tool(&tool_name, arguments, &project_context) {
617            Ok(value) => {
618                let text = match &value {
619                    serde_json::Value::String(s) => s.clone(),
620                    other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
621                };
622                ServerRoutingResult::Success(text)
623            }
624            Err(e) => ServerRoutingResult::Error(format!(
625                "Server-routed tool {tool_name} failed: {e}\nCheck connection: nebu-ctx status"
626            )),
627        }
628    })
629    .await;
630
631    result.unwrap_or_else(|_| {
632        ServerRoutingResult::Error("Server routing task panicked".to_string())
633    })
634}
635
636/// Merges tool definitions fetched from the configured server into the local manifest.
637///
638/// Only tool names listed in [`SERVER_ONLY_TOOLS`] are pulled from the remote manifest, so the
639/// local definitions always win for [`SERVER_PREFERRED_TOOLS`]. When the server is unreachable the
640/// local manifest is returned unchanged.
641async fn merge_remote_tool_defs(local_tools: Vec<Tool>) -> Vec<Tool> {
642    let Some(remote_tools) = load_server_only_tool_defs().await else {
643        return local_tools;
644    };
645
646    let mut merged = BTreeMap::new();
647    for tool in local_tools {
648        merged.insert(tool.name.to_string(), tool);
649    }
650    for tool in remote_tools {
651        merged.insert(tool.name.to_string(), tool);
652    }
653    merged.into_values().collect()
654}
655
656/// Fetches tool definitions for [`SERVER_ONLY_TOOLS`] from the configured server.
657async fn load_server_only_tool_defs() -> Option<Vec<Tool>> {
658    tokio::task::spawn_blocking(|| {
659        let client = crate::server_client::ServerClient::load().ok()?;
660        let remote = client.list_tools().ok()?;
661        Some(
662            remote
663                .tools
664                .into_iter()
665                .filter(|tool| SERVER_ONLY_TOOLS.contains(&tool.name.as_str()))
666                .map(|tool| {
667                    let input_schema = match tool.input_schema {
668                        serde_json::Value::Object(map) => map,
669                        _ => serde_json::Map::new(),
670                    };
671                    Tool::new(tool.name, tool.description, Arc::new(input_schema))
672                })
673                .collect::<Vec<_>>(),
674        )
675    })
676    .await
677    .ok()
678    .flatten()
679}
680
681pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
682    crate::instructions::build_instructions(crp_mode)
683}
684
685pub fn build_claude_code_instructions_for_test() -> String {
686    crate::instructions::claude_code_instructions()
687}
688
689const PROJECT_MARKERS: &[&str] = &[
690    ".git",
691    "Cargo.toml",
692    "package.json",
693    "go.mod",
694    "pyproject.toml",
695    "setup.py",
696    "pom.xml",
697    "build.gradle",
698    "Makefile",
699    ".lean-ctx.toml",
700];
701
702fn has_project_marker(dir: &std::path::Path) -> bool {
703    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
704}
705
706fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
707    if let Some(home) = dirs::home_dir() {
708        if dir == home {
709            return true;
710        }
711    }
712    let dir_str = dir.to_string_lossy();
713    dir_str.ends_with("/.claude")
714        || dir_str.ends_with("/.codex")
715        || dir_str.contains("/.claude/")
716        || dir_str.contains("/.codex/")
717}
718
719fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
720    std::process::Command::new("git")
721        .args(["rev-parse", "--show-toplevel"])
722        .current_dir(dir)
723        .stdout(std::process::Stdio::piped())
724        .stderr(std::process::Stdio::null())
725        .output()
726        .ok()
727        .and_then(|o| {
728            if o.status.success() {
729                String::from_utf8(o.stdout)
730                    .ok()
731                    .map(|s| s.trim().to_string())
732            } else {
733                None
734            }
735        })
736}
737
738pub fn derive_project_root_from_cwd() -> Option<String> {
739    let cwd = std::env::current_dir().ok()?;
740    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
741
742    if is_home_or_agent_dir(&canonical) {
743        return git_toplevel_from(&canonical);
744    }
745
746    if has_project_marker(&canonical) {
747        return Some(canonical.to_string_lossy().to_string());
748    }
749
750    if let Some(git_root) = git_toplevel_from(&canonical) {
751        return Some(git_root);
752    }
753
754    None
755}
756
757pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
758    crate::tool_defs::list_all_tool_defs()
759        .into_iter()
760        .map(|(name, desc, _)| (name, desc))
761        .collect()
762}
763
764pub fn tool_schemas_json_for_test() -> String {
765    crate::tool_defs::list_all_tool_defs()
766        .iter()
767        .map(|(name, _, schema)| format!("{}: {}", name, schema))
768        .collect::<Vec<_>>()
769        .join("\n")
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    #[test]
777    fn project_markers_detected() {
778        let tmp = tempfile::tempdir().unwrap();
779        let root = tmp.path().join("myproject");
780        std::fs::create_dir_all(&root).unwrap();
781        assert!(!has_project_marker(&root));
782
783        std::fs::create_dir(root.join(".git")).unwrap();
784        assert!(has_project_marker(&root));
785    }
786
787    #[test]
788    fn home_dir_detected_as_agent_dir() {
789        if let Some(home) = dirs::home_dir() {
790            assert!(is_home_or_agent_dir(&home));
791        }
792    }
793
794    #[test]
795    fn agent_dirs_detected() {
796        let claude = std::path::PathBuf::from("/home/user/.claude");
797        assert!(is_home_or_agent_dir(&claude));
798        let codex = std::path::PathBuf::from("/home/user/.codex");
799        assert!(is_home_or_agent_dir(&codex));
800        let project = std::path::PathBuf::from("/home/user/projects/myapp");
801        assert!(!is_home_or_agent_dir(&project));
802    }
803
804    #[test]
805    fn test_unified_tool_count() {
806        let tools = crate::tool_defs::unified_tool_defs();
807        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
808    }
809
810    #[test]
811    fn test_granular_tool_count() {
812        let tools = crate::tool_defs::granular_tool_defs();
813        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
814    }
815
816    #[test]
817    fn disabled_tools_filters_list() {
818        let all = crate::tool_defs::granular_tool_defs();
819        let total = all.len();
820        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
821        let filtered: Vec<_> = all
822            .into_iter()
823            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
824            .collect();
825        assert_eq!(filtered.len(), total - 2);
826        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
827        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
828    }
829
830    #[test]
831    fn empty_disabled_tools_returns_all() {
832        let all = crate::tool_defs::granular_tool_defs();
833        let total = all.len();
834        let disabled: Vec<String> = vec![];
835        let filtered: Vec<_> = all
836            .into_iter()
837            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
838            .collect();
839        assert_eq!(filtered.len(), total);
840    }
841
842    #[test]
843    fn misspelled_disabled_tool_is_silently_ignored() {
844        let all = crate::tool_defs::granular_tool_defs();
845        let total = all.len();
846        let disabled = ["ctx_nonexistent_tool".to_string()];
847        let filtered: Vec<_> = all
848            .into_iter()
849            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
850            .collect();
851        assert_eq!(filtered.len(), total);
852    }
853
854    #[test]
855    fn server_tool_constants_are_disjoint() {
856        for name in SERVER_ONLY_TOOLS {
857            assert!(
858                !SERVER_PREFERRED_TOOLS.contains(name),
859                "{name} appears in both SERVER_ONLY_TOOLS and SERVER_PREFERRED_TOOLS"
860            );
861        }
862    }
863
864    #[test]
865    fn ctx_brain_is_server_only_not_preferred() {
866        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_brain"));
867        assert!(!SERVER_PREFERRED_TOOLS.contains(&"ctx_brain"));
868    }
869
870    #[test]
871    fn ctx_knowledge_and_ctx_session_are_server_preferred() {
872        assert!(SERVER_PREFERRED_TOOLS.contains(&"ctx_knowledge"));
873        assert!(SERVER_PREFERRED_TOOLS.contains(&"ctx_session"));
874        assert!(!SERVER_ONLY_TOOLS.contains(&"ctx_knowledge"));
875        assert!(!SERVER_ONLY_TOOLS.contains(&"ctx_session"));
876    }
877
878    #[test]
879    fn analytics_tools_are_server_only() {
880        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_gain"));
881        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_cost"));
882        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_heatmap"));
883        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_stats"));
884    }
885
886    #[test]
887    fn ctx_brain_in_granular_tool_defs() {
888        let tools = crate::tool_defs::granular_tool_defs();
889        assert!(
890            tools.iter().any(|t| t.name.as_ref() == "ctx_brain"),
891            "ctx_brain must appear in the local manifest so Claude sees it even when offline"
892        );
893    }
894
895    #[test]
896    fn ctx_brain_stub_has_required_actions() {
897        let tools = crate::tool_defs::granular_tool_defs();
898        let brain = tools.iter().find(|t| t.name.as_ref() == "ctx_brain").unwrap();
899        let schema = serde_json::to_string(&*brain.input_schema).unwrap();
900        assert!(schema.contains("store"), "ctx_brain schema must include 'store' action");
901        assert!(schema.contains("recall"), "ctx_brain schema must include 'recall' action");
902        assert!(schema.contains("forget"), "ctx_brain schema must include 'forget' action");
903    }
904
905    #[test]
906    fn route_to_server_returns_not_configured_or_error_when_no_connection() {
907        // Without a saved connection, route_to_server must not panic. It should return
908        // NotConfigured or Error (never Success unless CI has a live server).
909        let rt = tokio::runtime::Builder::new_current_thread()
910            .enable_all()
911            .build()
912            .unwrap();
913        let result = rt.block_on(route_to_server("ctx_brain", &None));
914        match result {
915            ServerRoutingResult::Success(_) => {} // acceptable if CI has a live server
916            ServerRoutingResult::NotConfigured => {}
917            ServerRoutingResult::Error(_) => {}
918        }
919    }
920}