Skip to main content

nebu_ctx/mcp_server/
mod.rs

1mod dispatch;
2mod execute;
3pub mod helpers;
4
5use rmcp::handler::server::ServerHandler;
6use rmcp::model::*;
7use rmcp::service::{RequestContext, RoleServer};
8use rmcp::ErrorData;
9
10use crate::tools::{CrpMode, NebuCtxServer};
11
12/// Tools that ONLY exist on the configured server. No local fallback when offline.
13pub const SERVER_ONLY_TOOLS: &[&str] = &[
14    "ctx_brain",
15    "ctx_gain",
16    "ctx_cost",
17    "ctx_heatmap",
18    "ctx_stats",
19];
20
21const PUBLIC_TOOL_NAMES: &[&str] = &["ctx_read", "ctx_search", "ctx_tree", "ctx_shell", "ctx"];
22
23/// Tools that prefer server routing but fall back to local file storage when not configured.
24const SERVER_PREFERRED_TOOLS: &[&str] = &["ctx_knowledge", "ctx_session"];
25
26const SERVER_KNOWLEDGE_ACTIONS: &[&str] = &[
27    "remember",
28    "recall",
29    "search",
30    "status",
31    "remove",
32    "categories",
33    "timeline",
34    "consolidate",
35    "promote",
36    "upkeep",
37    "wakeup",
38    "triage",
39];
40
41/// Outcome of attempting to route a tool call to the server.
42enum ServerRoutingResult {
43    /// Server responded successfully; return this text to the caller.
44    Success(String),
45    /// No server connection is configured; caller should fall back to local handling.
46    NotConfigured,
47    /// Connection is configured but the call failed; return this error text to the caller.
48    Error(String),
49}
50
51fn prefers_server_route(
52    name: &str,
53    args: &Option<serde_json::Map<String, serde_json::Value>>,
54) -> bool {
55    if !SERVER_PREFERRED_TOOLS.contains(&name) {
56        return false;
57    }
58
59    if name != "ctx_knowledge" {
60        return true;
61    }
62
63    let action = args
64        .as_ref()
65        .and_then(|map| map.get("action"))
66        .and_then(|value| value.as_str())
67        .unwrap_or_default();
68    SERVER_KNOWLEDGE_ACTIONS.contains(&action)
69}
70
71impl ServerHandler for NebuCtxServer {
72    fn get_info(&self) -> ServerInfo {
73        let capabilities = ServerCapabilities::builder().enable_tools().build();
74
75        let instructions = crate::instructions::build_instructions(self.crp_mode);
76
77        InitializeResult::new(capabilities)
78            .with_server_info(Implementation::new("nebu-ctx", env!("CARGO_PKG_VERSION")))
79            .with_instructions(instructions)
80    }
81
82    async fn initialize(
83        &self,
84        request: InitializeRequestParams,
85        _context: RequestContext<RoleServer>,
86    ) -> Result<InitializeResult, ErrorData> {
87        let name = request.client_info.name.clone();
88        tracing::info!("MCP client connected: {:?}", name);
89        *self.client_name.write().await = name.clone();
90
91        let derived_root = derive_project_root_from_cwd();
92        let cwd_str = std::env::current_dir()
93            .ok()
94            .map(|p| p.to_string_lossy().to_string())
95            .unwrap_or_default();
96        {
97            let mut session = self.session.write().await;
98            if !cwd_str.is_empty() {
99                session.shell_cwd = Some(cwd_str.clone());
100            }
101            if let Some(ref root) = derived_root {
102                session.project_root = Some(root.clone());
103                tracing::info!("Project root set to: {root}");
104            } else if let Some(ref root) = session.project_root {
105                let root_path = std::path::Path::new(root);
106                let root_has_marker = has_project_marker(root_path);
107                let root_str = root_path.to_string_lossy();
108                let root_suspicious = root_str.contains("/.claude")
109                    || root_str.contains("/.codex")
110                    || root_str.contains("/var/folders/")
111                    || root_str.contains("/tmp/")
112                    || root_str.contains("\\.claude")
113                    || root_str.contains("\\.codex")
114                    || root_str.contains("\\AppData\\Local\\Temp")
115                    || root_str.contains("\\Temp\\");
116                if root_suspicious && !root_has_marker {
117                    session.project_root = None;
118                }
119            }
120            let _ = session.save();
121        }
122
123        let agent_name = name.clone();
124        let agent_root = derived_root.clone().unwrap_or_default();
125        let agent_id_handle = self.agent_id.clone();
126        tokio::task::spawn_blocking(move || {
127            if std::env::var("NEBU_CTX_HEADLESS").is_ok() {
128                return;
129            }
130            if let Some(home) = dirs::home_dir() {
131                let _ = crate::rules_inject::inject_all_rules(&home);
132            }
133            crate::hooks::refresh_installed_hooks();
134            crate::core::version_check::check_background();
135
136            if !agent_root.is_empty() {
137                let role = match agent_name.to_lowercase().as_str() {
138                    n if n.contains("cursor") => Some("coder"),
139                    n if n.contains("claude") => Some("coder"),
140                    n if n.contains("codex") => Some("coder"),
141                    n if n.contains("antigravity") || n.contains("gemini") => Some("explorer"),
142                    n if n.contains("review") => Some("reviewer"),
143                    n if n.contains("test") => Some("tester"),
144                    _ => None,
145                };
146                let env_role = std::env::var("NEBU_CTX_AGENT_ROLE").ok();
147                let effective_role = env_role.as_deref().or(role);
148                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
149                registry.cleanup_stale(24);
150                let id = registry.register("mcp", effective_role, &agent_root);
151                let _ = registry.save();
152                if let Ok(mut guard) = agent_id_handle.try_write() {
153                    *guard = Some(id);
154                }
155            }
156        });
157
158        let instructions =
159            crate::instructions::build_instructions_with_client(self.crp_mode, &name);
160        let capabilities = ServerCapabilities::builder().enable_tools().build();
161
162        Ok(InitializeResult::new(capabilities)
163            .with_server_info(Implementation::new("nebu-ctx", env!("CARGO_PKG_VERSION")))
164            .with_instructions(instructions))
165    }
166
167    async fn list_tools(
168        &self,
169        _request: Option<PaginatedRequestParams>,
170        _context: RequestContext<RoleServer>,
171    ) -> Result<ListToolsResult, ErrorData> {
172        let all_tools = crate::tool_defs::unified_tool_defs();
173
174        let disabled = crate::core::config::Config::load().disabled_tools_effective();
175        let tools = if disabled.is_empty() {
176            all_tools
177        } else {
178            all_tools
179                .into_iter()
180                .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
181                .collect()
182        };
183
184        let tools = {
185            let active = self.workflow.read().await.clone();
186            if let Some(run) = active {
187                if let Some(state) = run.spec.state(&run.current) {
188                    if let Some(allowed) = &state.allowed_tools {
189                        let mut allow: std::collections::HashSet<&str> =
190                            allowed.iter().map(|s| s.as_str()).collect();
191                        allow.insert("ctx");
192                        allow.insert("ctx_workflow");
193                        return Ok(ListToolsResult {
194                            tools: tools
195                                .into_iter()
196                                .filter(|t| allow.contains(t.name.as_ref()))
197                                .collect(),
198                            ..Default::default()
199                        });
200                    }
201                }
202            }
203            tools
204        };
205
206        Ok(ListToolsResult {
207            tools,
208            ..Default::default()
209        })
210    }
211
212    async fn call_tool(
213        &self,
214        request: CallToolRequestParams,
215        _context: RequestContext<RoleServer>,
216    ) -> Result<CallToolResult, ErrorData> {
217        self.check_idle_expiry().await;
218
219        let original_name = request.name.as_ref().to_string();
220        if !PUBLIC_TOOL_NAMES.contains(&original_name.as_str()) {
221            return Err(ErrorData::invalid_params(
222                "Public MCP surface only supports: ctx_read, ctx_search, ctx_tree, ctx_shell, ctx",
223                None,
224            ));
225        }
226        let (resolved_name, resolved_args) = match original_name.as_str() {
227            "ctx" => {
228                let arguments = request.arguments.unwrap_or_default();
229                if arguments.get("tool").is_some() {
230                    return Err(ErrorData::invalid_params(
231                        "ctx now requires 'domain' + 'action'; 'tool' is no longer supported",
232                        None,
233                    ));
234                }
235                let domain = arguments
236                    .get("domain")
237                    .and_then(|v| v.as_str())
238                    .ok_or_else(|| {
239                        ErrorData::invalid_params("'domain' is required for ctx meta-tool", None)
240                    })?;
241                let action = arguments
242                    .get("action")
243                    .and_then(|v| v.as_str())
244                    .filter(|v| !v.trim().is_empty())
245                    .ok_or_else(|| {
246                        ErrorData::invalid_params("'action' is required for ctx meta-tool", None)
247                    })?;
248                let mut args = arguments.clone();
249                args.remove("domain");
250                let resolved = match domain {
251                    "memory" => match action {
252                        "task" | "finding" | "decision" | "save" | "load" | "status" | "reset" | "list" | "cleanup" => "ctx_session",
253                        "recall" | "search" | "consolidate" | "promote" | "upkeep" | "triage" | "timeline" | "categories" | "wakeup" | "remove" => "ctx_knowledge",
254                        "store" | "set" | "remember" => {
255                            args.insert("action".to_string(), serde_json::Value::String("remember".to_string()));
256                            if !args.contains_key("category") {
257                                args.insert("category".to_string(), serde_json::Value::String("general".to_string()));
258                            }
259                            "ctx_knowledge"
260                        }
261                        _ => return Err(ErrorData::invalid_params(
262                            "Unknown memory action. Use one of: task, finding, decision, save, load, status, reset, list, cleanup, store, set, remember, recall, search, categories, timeline, consolidate, promote, upkeep, triage, wakeup, remove",
263                            None,
264                        )),
265                    },
266                    "context" => match action {
267                        "overview" => "ctx_overview",
268                        "preload" => "ctx_preload",
269                        "prefetch" => "ctx_prefetch",
270                        "status" => "ctx_context",
271                        "compress" => "ctx_compress",
272                        "fill" => "ctx_fill",
273                        "intent" => "ctx_intent",
274                        "response" => "ctx_response",
275                        _ => return Err(ErrorData::invalid_params("Unknown context action", None)),
276                    },
277                    "graph" => match action {
278                        "build" | "related" | "symbol" | "status" => "ctx_graph",
279                        "impact" => {
280                            args.insert("action".to_string(), serde_json::Value::String("analyze".to_string()));
281                            "ctx_impact"
282                        }
283                        "chain" => "ctx_impact",
284                        "architecture" => {
285                            args.insert("action".to_string(), serde_json::Value::String("overview".to_string()));
286                            "ctx_architecture"
287                        }
288                        "callers" => "ctx_callers",
289                        "callees" => "ctx_callees",
290                        "diagram" => "ctx_graph_diagram",
291                        _ => return Err(ErrorData::invalid_params("Unknown graph action", None)),
292                    },
293                    "analytics" => match action {
294                        "report" => {
295                            args.insert("action".to_string(), serde_json::Value::String("report".to_string()));
296                            "ctx_gain"
297                        }
298                        "cost" => {
299                            args.insert("action".to_string(), serde_json::Value::String("report".to_string()));
300                            "ctx_cost"
301                        }
302                        "heatmap" => {
303                            args.insert("action".to_string(), serde_json::Value::String("status".to_string()));
304                            "ctx_heatmap"
305                        }
306                        "stats" => {
307                            args.insert("action".to_string(), serde_json::Value::String("report".to_string()));
308                            "ctx_stats"
309                        }
310                        "feedback" => {
311                            args.insert("action".to_string(), serde_json::Value::String("report".to_string()));
312                            "ctx_feedback"
313                        }
314                        "wrapped" => "ctx_wrapped",
315                        "analyze" => "ctx_analyze",
316                        "discover" => "ctx_discover",
317                        _ => return Err(ErrorData::invalid_params("Unknown analytics action", None)),
318                    },
319                    "agents" => match action {
320                        "register" | "post" | "read" | "status" | "handoff" | "sync" | "diary" | "recall_diary" | "diaries" | "list" | "info" => "ctx_agent",
321                        "push" | "pull" | "clear" => "ctx_share",
322                        "create" | "update" | "get" | "cancel" | "message" => "ctx_task",
323                        "start" | "transition" | "complete" | "evidence_add" | "evidence_list" | "stop" => "ctx_workflow",
324                        _ => return Err(ErrorData::invalid_params("Unknown agents action", None)),
325                    },
326                    "inspect" => match action {
327                        "dedup" => "ctx_dedup",
328                        _ => return Err(ErrorData::invalid_params("Unknown inspect action", None)),
329                    },
330                    _ => {
331                        return Err(ErrorData::invalid_params(
332                            "Unknown ctx domain. Use one of: memory, context, graph, analytics, agents, inspect",
333                            None,
334                        ))
335                    }
336                };
337                (resolved.to_string(), Some(args))
338            }
339            "ctx_read" => {
340                let mut args = request.arguments.unwrap_or_default();
341                let target = args
342                    .get("target")
343                    .and_then(|v| v.as_str())
344                    .unwrap_or("file");
345                let resolved = match target {
346                    "file" => "ctx_read",
347                    "files" => "ctx_multi_read",
348                    "symbol" => {
349                        if let Some(path) = args.remove("path") {
350                            args.entry("file".to_string()).or_insert(path);
351                        }
352                        "ctx_symbol"
353                    }
354                    "outline" => "ctx_outline",
355                    "archive" => "ctx_expand",
356                    _ => return Err(ErrorData::invalid_params("Unknown ctx_read target", None)),
357                };
358                args.remove("target");
359                (resolved.to_string(), Some(args))
360            }
361            "ctx_search" => {
362                let mut args = request.arguments.unwrap_or_default();
363                let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("regex");
364                let resolved = match mode {
365                    "regex" => "ctx_search",
366                    "semantic" => "ctx_semantic_search",
367                    _ => return Err(ErrorData::invalid_params("Unknown ctx_search mode", None)),
368                };
369                args.remove("mode");
370                (resolved.to_string(), Some(args))
371            }
372            _ => (original_name, request.arguments),
373        };
374        let name = resolved_name.as_str();
375        let args = &resolved_args;
376
377        // Route server-only and server-preferred tools through the configured server.
378        let server_fallback_warning = if SERVER_ONLY_TOOLS.contains(&name) {
379            match route_to_server(name, args).await {
380                ServerRoutingResult::Success(s) => {
381                    // Record telemetry so the dashboard reflects hosted tool usage.
382                    self.record_call(name, 0, 0, None).await;
383                    return Ok(CallToolResult::success(vec![Content::text(s)]));
384                }
385                ServerRoutingResult::NotConfigured => {
386                    let msg = format!("{name} requires a server connection. Run: nebu-ctx connect");
387                    return Ok(CallToolResult::success(vec![Content::text(msg)]));
388                }
389                ServerRoutingResult::Error(e) => {
390                    return Ok(CallToolResult::success(vec![Content::text(e)]));
391                }
392            }
393        } else if prefers_server_route(name, args) {
394            // When a server is configured, fail hard rather than silently
395            // writing to local JSON. Local fallback only applies when no server
396            // server has been set up at all.
397            let server_is_configured =
398                matches!(crate::config::load_connection(), Ok(Some(_)) | Err(_));
399            match route_to_server(name, args).await {
400                ServerRoutingResult::Success(s) => {
401                    // Record telemetry so the dashboard reflects hosted tool usage.
402                    self.record_call(name, 0, 0, None).await;
403                    return Ok(CallToolResult::success(vec![Content::text(s)]));
404                }
405                ServerRoutingResult::NotConfigured if server_is_configured => {
406                    // Config exists but load/connect failed transiently — fail hard.
407                    return Ok(CallToolResult::success(vec![Content::text(format!(
408                        "{name}: server is configured but unreachable. Check: nebu-ctx status"
409                    ))]));
410                }
411                ServerRoutingResult::NotConfigured => Some(
412                    "\n\nâš  Running locally (no server connection). Data stored in .nebu-ctx/ only.\n  To enable hosted persistence: nebu-ctx connect"
413                        .to_string(),
414                ),
415                ServerRoutingResult::Error(e) => {
416                    return Ok(CallToolResult::success(vec![Content::text(e)]))
417                }
418            }
419        } else {
420            None
421        };
422
423        if name != "ctx_workflow" {
424            let active = self.workflow.read().await.clone();
425            if let Some(run) = active {
426                if let Some(state) = run.spec.state(&run.current) {
427                    if let Some(allowed) = &state.allowed_tools {
428                        let allowed_ok = allowed.iter().any(|t| t == name) || name == "ctx";
429                        if !allowed_ok {
430                            let mut shown = allowed.clone();
431                            shown.sort();
432                            shown.truncate(30);
433                            return Ok(CallToolResult::success(vec![Content::text(format!(
434                                "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed ({} shown): {}",
435                                run.spec.name,
436                                run.current,
437                                shown.len(),
438                                shown.join(", ")
439                            ))]));
440                        }
441                    }
442                }
443            }
444        }
445
446        let skip_auto_context = name == "ctx_shell"
447            || (name == "ctx"
448            && args.as_ref().is_some_and(|args| {
449                let domain = args.get("domain").and_then(|value| value.as_str());
450                let action = args.get("action").and_then(|value| value.as_str());
451                domain == Some("memory") && matches!(action, Some("promote" | "triage"))
452            }))
453            || (name == "ctx_knowledge"
454                && args.as_ref().is_some_and(|args| {
455                    matches!(
456                        args.get("action").and_then(|value| value.as_str()),
457                        Some("promote" | "triage")
458                    )
459                }));
460
461        let auto_context = if skip_auto_context {
462            None
463        } else {
464            let task = {
465                let session = self.session.read().await;
466                session.task.as_ref().map(|t| t.description.clone())
467            };
468            let project_root = {
469                let session = self.session.read().await;
470                session.project_root.clone()
471            };
472            let mut cache = self.cache.write().await;
473            crate::tools::autonomy::session_lifecycle_pre_hook(
474                &self.autonomy,
475                name,
476                &mut cache,
477                task.as_deref(),
478                project_root.as_deref(),
479                self.crp_mode,
480            )
481        };
482
483        let throttle_result = {
484            let fp = args
485                .as_ref()
486                .map(|a| {
487                    crate::core::loop_detection::LoopDetector::fingerprint(
488                        &serde_json::Value::Object(a.clone()),
489                    )
490                })
491                .unwrap_or_default();
492            let mut detector = self.loop_detector.write().await;
493
494            let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
495            let is_search_shell = name == "ctx_shell" && {
496                let cmd = args
497                    .as_ref()
498                    .and_then(|a| a.get("command"))
499                    .and_then(|v| v.as_str())
500                    .unwrap_or("");
501                crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
502            };
503
504            if is_search || is_search_shell {
505                let search_pattern = args.as_ref().and_then(|a| {
506                    a.get("pattern")
507                        .or_else(|| a.get("query"))
508                        .and_then(|v| v.as_str())
509                });
510                let shell_pattern = if is_search_shell {
511                    args.as_ref()
512                        .and_then(|a| a.get("command"))
513                        .and_then(|v| v.as_str())
514                        .and_then(helpers::extract_search_pattern_from_command)
515                } else {
516                    None
517                };
518                let pat = search_pattern.or(shell_pattern.as_deref());
519                detector.record_search(name, &fp, pat)
520            } else {
521                detector.record_call(name, &fp)
522            }
523        };
524
525        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
526            let msg = throttle_result.message.unwrap_or_default();
527            return Ok(CallToolResult::success(vec![Content::text(msg)]));
528        }
529
530        let throttle_warning =
531            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
532                throttle_result.message.clone()
533            } else {
534                None
535            };
536
537        let tool_start = std::time::Instant::now();
538        let result_text = self.dispatch_tool(name, args).await?;
539
540        let mut result_text = result_text;
541
542        // Archive large tool outputs before density compression (zero-loss recovery)
543        let archive_hint = {
544            use crate::core::archive;
545            let archivable = matches!(
546                name,
547                "ctx_shell"
548                    | "ctx_read"
549                    | "ctx_multi_read"
550                    | "ctx_smart_read"
551                    | "ctx_execute"
552                    | "ctx_search"
553                    | "ctx_tree"
554            );
555            if archivable && archive::should_archive(&result_text) {
556                let cmd = helpers::get_str(args, "command")
557                    .or_else(|| helpers::get_str(args, "path"))
558                    .unwrap_or_default();
559                let session_id = self.session.read().await.id.clone();
560                let tokens = crate::core::tokens::count_tokens(&result_text);
561                archive::store(name, &cmd, &result_text, Some(&session_id))
562                    .map(|id| archive::format_hint(&id, result_text.len(), tokens))
563            } else {
564                None
565            }
566        };
567
568        {
569            let config = crate::core::config::Config::load();
570            let density = crate::core::config::OutputDensity::effective(&config.output_density);
571            result_text = crate::core::protocol::compress_output(&result_text, &density);
572        }
573
574        if let Some(hint) = archive_hint {
575            result_text = format!("{result_text}\n{hint}");
576        }
577
578        if let Some(ctx) = auto_context {
579            result_text = format!("{ctx}\n\n{result_text}");
580        }
581
582        if let Some(warning) = throttle_warning {
583            result_text = format!("{result_text}\n\n{warning}");
584        }
585
586        if let Some(offline_note) = server_fallback_warning {
587            result_text = format!("{result_text}{offline_note}");
588        }
589
590        if name == "ctx_read" {
591            let read_path = self
592                .resolve_path_or_passthrough(&helpers::get_str(args, "path").unwrap_or_default())
593                .await;
594            let project_root = {
595                let session = self.session.read().await;
596                session.project_root.clone()
597            };
598            let mut cache = self.cache.write().await;
599            let enrich = crate::tools::autonomy::enrich_after_read(
600                &self.autonomy,
601                &mut cache,
602                &read_path,
603                project_root.as_deref(),
604            );
605            if let Some(hint) = enrich.related_hint {
606                result_text = format!("{result_text}\n{hint}");
607            }
608
609            crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache);
610        }
611
612        if name == "ctx_shell" {
613            let cmd = helpers::get_str(args, "command").unwrap_or_default();
614            let output_tokens = crate::core::tokens::count_tokens(&result_text);
615            let calls = self.tool_calls.read().await;
616            let last_original = calls.last().map(|c| c.original_tokens).unwrap_or(0);
617            drop(calls);
618            if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
619                &self.autonomy,
620                &cmd,
621                last_original,
622                output_tokens,
623            ) {
624                result_text = format!("{result_text}\n{hint}");
625            }
626        }
627
628        {
629            let input = helpers::canonical_args_string(args);
630            let input_md5 = helpers::md5_hex(&input);
631            let output_md5 = helpers::md5_hex(&result_text);
632            let action = helpers::get_str(args, "action");
633            let agent_id = self.agent_id.read().await.clone();
634            let client_name = self.client_name.read().await.clone();
635            let mut explicit_intent: Option<(
636                crate::core::intent_protocol::IntentRecord,
637                Option<String>,
638                String,
639            )> = None;
640
641            {
642                let empty_args = serde_json::Map::new();
643                let args_map = args.as_ref().unwrap_or(&empty_args);
644                let mut session = self.session.write().await;
645                session.record_tool_receipt(
646                    name,
647                    action.as_deref(),
648                    &input_md5,
649                    &output_md5,
650                    agent_id.as_deref(),
651                    Some(&client_name),
652                );
653
654                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
655                    name,
656                    action.as_deref(),
657                    args_map,
658                    session.project_root.as_deref(),
659                ) {
660                    let is_explicit =
661                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
662                    let root = session.project_root.clone();
663                    let sid = session.id.clone();
664                    session.record_intent(intent.clone());
665                    if is_explicit {
666                        explicit_intent = Some((intent, root, sid));
667                    }
668                }
669                if session.should_save() {
670                    let _ = session.save();
671                }
672            }
673
674            if let Some((intent, root, session_id)) = explicit_intent {
675                crate::core::intent_protocol::apply_side_effects(
676                    &intent,
677                    root.as_deref(),
678                    &session_id,
679                );
680            }
681
682            // Autopilot: consolidation loop (silent, deterministic, budgeted).
683            if self.autonomy.is_enabled() {
684                let (calls, project_root) = {
685                    let session = self.session.read().await;
686                    (session.stats.total_tool_calls, session.project_root.clone())
687                };
688
689                if let Some(root) = project_root {
690                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
691                        let root_clone = root.clone();
692                        tokio::task::spawn_blocking(move || {
693                            if crate::core::consolidation_engine::consolidate_latest(
694                                &root_clone,
695                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
696                            )
697                            .is_ok()
698                            {
699                                crate::server_client::post_knowledge_to_server(&root_clone);
700                            }
701                        });
702                    }
703                }
704            }
705
706            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
707            let input_tokens = crate::core::tokens::count_tokens(&input) as u64;
708            let output_tokens = crate::core::tokens::count_tokens(&result_text) as u64;
709            let mut store = crate::core::a2a::cost_attribution::CostStore::load();
710            store.record_tool_call(&agent_key, &client_name, name, input_tokens, output_tokens);
711            let _ = store.save();
712        }
713
714        let skip_checkpoint = matches!(
715            name,
716            "ctx_compress"
717                | "ctx_metrics"
718                | "ctx_benchmark"
719                | "ctx_analyze"
720                | "ctx_cache"
721                | "ctx_discover"
722                | "ctx_dedup"
723                | "ctx_session"
724                | "ctx_knowledge"
725                | "ctx_agent"
726                | "ctx_share"
727                | "ctx_wrapped"
728                | "ctx_overview"
729                | "ctx_preload"
730                | "ctx_cost"
731                | "ctx_gain"
732                | "ctx_heatmap"
733                | "ctx_stats"
734                | "ctx_task"
735                | "ctx_impact"
736                | "ctx_architecture"
737                | "ctx_workflow"
738        );
739
740        if !skip_checkpoint && self.increment_and_check() {
741            if let Some(checkpoint) = self.auto_checkpoint().await {
742                let combined = format!(
743                    "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
744                    self.checkpoint_interval
745                );
746                return Ok(CallToolResult::success(vec![Content::text(combined)]));
747            }
748        }
749
750        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
751        if tool_duration_ms > 100 {
752            NebuCtxServer::append_tool_call_log(
753                name,
754                tool_duration_ms,
755                0,
756                0,
757                None,
758                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
759            );
760        }
761
762        Ok(CallToolResult::success(vec![Content::text(result_text)]))
763    }
764}
765
766/// Routes a tool call to the configured server, enriching it with the current git context.
767///
768/// Returns [`ServerRoutingResult::Success`] when the server responds,
769/// [`ServerRoutingResult::NotConfigured`] when no connection is saved, and
770/// [`ServerRoutingResult::Error`] when the connection exists but the call fails.
771async fn route_to_server(
772    name: &str,
773    args: &Option<serde_json::Map<String, serde_json::Value>>,
774) -> ServerRoutingResult {
775    let tool_name = name.to_string();
776    let arguments = args.clone().unwrap_or_default();
777
778    let result = tokio::task::spawn_blocking(move || {
779        let connection = match crate::config::load_connection() {
780            Ok(Some(connection)) => connection,
781            Ok(None) => return ServerRoutingResult::NotConfigured,
782            Err(error) => {
783                return ServerRoutingResult::Error(format!(
784                    "Saved server connection is invalid: {error}\nRun: nebu-ctx connect --endpoint <url> --token <token>"
785                ))
786            }
787        };
788        let client = crate::server_client::ServerClient::new(connection);
789        let current_directory = match std::env::current_dir() {
790            Ok(d) => d,
791            Err(e) => {
792                return ServerRoutingResult::Error(format!(
793                    "Could not determine working directory: {e}"
794                ))
795            }
796        };
797        let project_context = crate::git_context::discover_project_context(&current_directory);
798        match client.call_tool(&tool_name, arguments, &project_context) {
799            Ok(value) => {
800                let text = match &value {
801                    serde_json::Value::String(s) => s.clone(),
802                    other => {
803                        serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
804                    }
805                };
806                ServerRoutingResult::Success(text)
807            }
808            Err(e) => ServerRoutingResult::Error(format!(
809                "Server-routed tool {tool_name} failed: {e}\nCheck connection: nebu-ctx status"
810            )),
811        }
812    })
813    .await;
814
815    result
816        .unwrap_or_else(|_| ServerRoutingResult::Error("Server routing task panicked".to_string()))
817}
818
819/// Merges tool definitions fetched from the configured server into the local manifest.
820///
821/// Only tool names listed in [`SERVER_ONLY_TOOLS`] are pulled from the remote manifest, so the
822/// local definitions always win for [`SERVER_PREFERRED_TOOLS`]. When the server is unreachable the
823/// local manifest is returned unchanged.
824pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
825    crate::instructions::build_instructions(crp_mode)
826}
827
828pub fn build_claude_code_instructions_for_test() -> String {
829    crate::instructions::claude_code_instructions()
830}
831
832const PROJECT_MARKERS: &[&str] = &[
833    ".git",
834    "Cargo.toml",
835    "package.json",
836    "go.mod",
837    "pyproject.toml",
838    "setup.py",
839    "pom.xml",
840    "build.gradle",
841    "Makefile",
842    ".lean-ctx.toml",
843];
844
845fn has_project_marker(dir: &std::path::Path) -> bool {
846    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
847}
848
849fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
850    if let Some(home) = dirs::home_dir() {
851        if dir == home {
852            return true;
853        }
854    }
855    let dir_str = dir.to_string_lossy();
856    dir_str.ends_with("/.claude")
857        || dir_str.ends_with("/.codex")
858        || dir_str.contains("/.claude/")
859        || dir_str.contains("/.codex/")
860}
861
862fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
863    std::process::Command::new("git")
864        .args(["rev-parse", "--show-toplevel"])
865        .current_dir(dir)
866        .stdout(std::process::Stdio::piped())
867        .stderr(std::process::Stdio::null())
868        .output()
869        .ok()
870        .and_then(|o| {
871            if o.status.success() {
872                String::from_utf8(o.stdout)
873                    .ok()
874                    .map(|s| s.trim().to_string())
875            } else {
876                None
877            }
878        })
879}
880
881pub fn derive_project_root_from_cwd() -> Option<String> {
882    let cwd = std::env::current_dir().ok()?;
883    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
884
885    if is_home_or_agent_dir(&canonical) {
886        return git_toplevel_from(&canonical);
887    }
888
889    if has_project_marker(&canonical) {
890        return Some(canonical.to_string_lossy().to_string());
891    }
892
893    if let Some(git_root) = git_toplevel_from(&canonical) {
894        return Some(git_root);
895    }
896
897    None
898}
899
900pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
901    crate::tool_defs::public_discoverable_tool_defs()
902        .into_iter()
903        .map(|(name, desc, _)| (name, desc))
904        .collect()
905}
906
907pub fn tool_schemas_json_for_test() -> String {
908    crate::tool_defs::public_discoverable_tool_defs()
909        .iter()
910        .map(|(name, _, schema)| format!("{}: {}", name, schema))
911        .collect::<Vec<_>>()
912        .join("\n")
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn project_markers_detected() {
921        let tmp = tempfile::tempdir().unwrap();
922        let root = tmp.path().join("myproject");
923        std::fs::create_dir_all(&root).unwrap();
924        assert!(!has_project_marker(&root));
925
926        std::fs::create_dir(root.join(".git")).unwrap();
927        assert!(has_project_marker(&root));
928    }
929
930    #[test]
931    fn home_dir_detected_as_agent_dir() {
932        if let Some(home) = dirs::home_dir() {
933            assert!(is_home_or_agent_dir(&home));
934        }
935    }
936
937    #[test]
938    fn agent_dirs_detected() {
939        let claude = std::path::PathBuf::from("/home/user/.claude");
940        assert!(is_home_or_agent_dir(&claude));
941        let codex = std::path::PathBuf::from("/home/user/.codex");
942        assert!(is_home_or_agent_dir(&codex));
943        let project = std::path::PathBuf::from("/home/user/projects/myapp");
944        assert!(!is_home_or_agent_dir(&project));
945    }
946
947    #[test]
948    fn test_unified_tool_count() {
949        let tools = crate::tool_defs::unified_tool_defs();
950        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
951    }
952
953    #[test]
954    fn public_tool_count_is_exactly_five() {
955        let tools = crate::tool_defs::unified_tool_defs();
956        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
957        assert_eq!(
958            names,
959            vec!["ctx_read", "ctx_search", "ctx_tree", "ctx_shell", "ctx"],
960            "Expected the canonical 5-tool public MCP surface"
961        );
962    }
963
964    #[test]
965    fn public_manifest_contains_only_public_tools() {
966        let manifest = crate::core::mcp_manifest::manifest_value();
967        let tools = manifest
968            .get("tools")
969            .and_then(|t| t.as_array())
970            .expect("manifest tools should be an array");
971
972        assert_eq!(
973            tools.len(),
974            5,
975            "Manifest should expose exactly 5 public tools"
976        );
977    }
978
979    #[test]
980    fn ctx_requires_domain_and_action_in_public_mode() {
981        let rt = tokio::runtime::Builder::new_current_thread()
982            .enable_all()
983            .build()
984            .unwrap();
985        let engine = crate::engine::ContextEngine::new();
986        let err = rt
987            .block_on(engine.call_tool_text(
988                "ctx",
989                Some(serde_json::json!({ "tool": "knowledge", "action": "recall" })),
990            ))
991            .expect_err("ctx(tool=...) should be rejected");
992
993        assert!(
994            err.to_string().contains("domain") || err.to_string().contains("tool"),
995            "ctx should reject tool= style calls in favor of domain/action: {err}"
996        );
997    }
998
999    #[test]
1000    fn claude_code_instructions_do_not_reference_ctx_edit() {
1001        let instructions = build_claude_code_instructions_for_test();
1002        assert!(
1003            !instructions.contains("ctx_edit"),
1004            "Public runtime instructions must not recommend private ctx_edit"
1005        );
1006    }
1007
1008    #[test]
1009    fn ctx_read_symbol_target_honors_path_scope() {
1010        let rt = tokio::runtime::Builder::new_current_thread()
1011            .enable_all()
1012            .build()
1013            .unwrap();
1014        let engine = crate::engine::ContextEngine::new();
1015        let text = rt
1016            .block_on(engine.call_tool_text(
1017                "ctx_read",
1018                Some(serde_json::json!({
1019                    "target": "symbol",
1020                    "name": "handle",
1021                    "path": "client/src/tools/ctx_symbol.rs"
1022                })),
1023            ))
1024            .expect("ctx_read(symbol) with path scope should succeed");
1025
1026        assert!(
1027            !text.contains("matches for 'handle'"),
1028            "path-scoped symbol reads should not degrade into ambiguous multi-match output: {text}"
1029        );
1030    }
1031
1032    #[test]
1033    fn ctx_read_files_target_remains_the_public_batch_read_entrypoint() {
1034        let tools = crate::tool_defs::unified_tool_defs();
1035        let read = tools
1036            .iter()
1037            .find(|tool| tool.name.as_ref() == "ctx_read")
1038            .expect("ctx_read should remain public");
1039        let schema = serde_json::to_string(&*read.input_schema).unwrap();
1040
1041        assert!(
1042            schema.contains("target") && schema.contains("files") && schema.contains("paths"),
1043            "ctx_read public schema must continue to advertise target=files batch reads: {schema}"
1044        );
1045        assert!(
1046            !schema.contains("ctx_multi_read"),
1047            "public ctx_read schema should not leak private ctx_multi_read details: {schema}"
1048        );
1049    }
1050
1051    #[test]
1052    fn ctx_read_files_target_executes_batch_reads() {
1053        let _lock = crate::core::data_dir::test_env_lock();
1054        let data = tempfile::tempdir().unwrap();
1055        let repo = tempfile::tempdir().unwrap();
1056        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1057        std::env::set_var("NEBU_CTX_HOME", data.path());
1058        std::fs::create_dir(repo.path().join(".git")).unwrap();
1059
1060        let first = repo.path().join("one.txt");
1061        let second = repo.path().join("two.txt");
1062        std::fs::write(&first, "alpha\n").unwrap();
1063        std::fs::write(&second, "beta\n").unwrap();
1064
1065        let first_path = crate::hooks::normalize_tool_path(&first.to_string_lossy());
1066        let second_path = crate::hooks::normalize_tool_path(&second.to_string_lossy());
1067
1068        let rt = tokio::runtime::Builder::new_current_thread()
1069            .enable_all()
1070            .build()
1071            .unwrap();
1072        let engine = crate::engine::ContextEngine::with_project_root(repo.path());
1073        let text = rt
1074            .block_on(engine.call_tool_text(
1075                "ctx_read",
1076                Some(serde_json::json!({
1077                    "target": "files",
1078                    "paths": [first_path, second_path],
1079                    "mode": "full"
1080                })),
1081            ))
1082            .expect("ctx_read(files) should succeed");
1083
1084        assert!(
1085            text.contains("Read 2 files"),
1086            "unexpected batch read text: {text}"
1087        );
1088        assert!(
1089            text.contains("one.txt"),
1090            "missing first file in batch read: {text}"
1091        );
1092        assert!(
1093            text.contains("two.txt"),
1094            "missing second file in batch read: {text}"
1095        );
1096
1097        std::env::remove_var("NEBU_CTX_DATA_DIR");
1098        std::env::remove_var("NEBU_CTX_HOME");
1099    }
1100
1101    #[test]
1102    fn analytics_report_is_accepted_in_public_mode() {
1103        let rt = tokio::runtime::Builder::new_current_thread()
1104            .enable_all()
1105            .build()
1106            .unwrap();
1107        let engine = crate::engine::ContextEngine::new();
1108        rt.block_on(engine.call_tool_text(
1109            "ctx",
1110            Some(serde_json::json!({ "domain": "analytics", "action": "report" })),
1111        ))
1112        .expect("ctx(domain=analytics, action=report) should be part of the public contract");
1113    }
1114
1115    #[test]
1116    fn memory_recall_does_not_require_server_connection() {
1117        let rt = tokio::runtime::Builder::new_current_thread()
1118            .enable_all()
1119            .build()
1120            .unwrap();
1121        let engine = crate::engine::ContextEngine::new();
1122        let text = rt
1123            .block_on(engine.call_tool_text(
1124                "ctx",
1125                Some(serde_json::json!({
1126                    "domain": "memory",
1127                    "action": "recall",
1128                    "query": "session state decisions"
1129                })),
1130            ))
1131            .expect("ctx(domain=memory, action=recall) should succeed");
1132
1133        assert!(
1134            !text.contains("requires a server connection"),
1135            "Public memory recall should not hard-require the hosted ctx_brain path: {text}"
1136        );
1137    }
1138
1139    #[test]
1140    fn memory_search_stays_project_scoped_locally() {
1141        let _lock = crate::core::data_dir::test_env_lock();
1142        let data = tempfile::tempdir().unwrap();
1143        let repo_a = tempfile::tempdir().unwrap();
1144        let repo_b = tempfile::tempdir().unwrap();
1145        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1146        std::env::set_var("NEBU_CTX_HOME", data.path());
1147        std::env::remove_var("NEBU_CTX_HTTP_TOKEN");
1148        std::fs::create_dir(repo_a.path().join(".git")).unwrap();
1149        std::fs::create_dir(repo_b.path().join(".git")).unwrap();
1150
1151        let repo_a_root = repo_a.path().to_string_lossy().to_string();
1152        let repo_b_root = repo_b.path().to_string_lossy().to_string();
1153        let mut knowledge_a = crate::core::knowledge::ProjectKnowledge::load_or_create(&repo_a_root);
1154        let mut knowledge_b = crate::core::knowledge::ProjectKnowledge::load_or_create(&repo_b_root);
1155        let _ = knowledge_a.remember(
1156            "decision",
1157            "owner",
1158            "local search should stay in repo a",
1159            "session-a",
1160            0.9,
1161        );
1162        let _ = knowledge_b.remember(
1163            "decision",
1164            "owner",
1165            "other project only",
1166            "session-b",
1167            0.9,
1168        );
1169        knowledge_a.save().unwrap();
1170        knowledge_b.save().unwrap();
1171
1172        let rt = tokio::runtime::Builder::new_current_thread()
1173            .enable_all()
1174            .build()
1175            .unwrap();
1176        let engine = crate::engine::ContextEngine::with_project_root(repo_a.path());
1177        let text = rt
1178            .block_on(engine.call_tool_text(
1179                "ctx",
1180                Some(serde_json::json!({
1181                    "domain": "memory",
1182                    "action": "search",
1183                    "query": "local repo a"
1184                })),
1185            ))
1186            .expect("memory search should succeed locally");
1187
1188        assert!(text.contains("local search should stay in repo a"));
1189        assert!(!text.contains("other project only"));
1190
1191        std::env::remove_var("NEBU_CTX_DATA_DIR");
1192        std::env::remove_var("NEBU_CTX_HOME");
1193    }
1194
1195    #[test]
1196    fn memory_write_aliases_do_not_require_category() {
1197        let _lock = crate::core::data_dir::test_env_lock();
1198        let rt = tokio::runtime::Builder::new_current_thread()
1199            .enable_all()
1200            .build()
1201            .unwrap();
1202        let engine = crate::engine::ContextEngine::new();
1203
1204        for action in ["store", "set", "remember"] {
1205            let text = rt
1206                .block_on(engine.call_tool_text(
1207                    "ctx",
1208                    Some(serde_json::json!({
1209                        "domain": "memory",
1210                        "action": action,
1211                        "key": format!("alias-{action}"),
1212                        "value": "memory alias smoke"
1213                    })),
1214                ))
1215                .expect("memory write alias should succeed");
1216
1217            assert!(
1218                text.contains("Remembered")
1219                    || text.contains("remembered")
1220                    || text.contains("\"remembered\":true")
1221                    || text.contains("\"ok\":true")
1222                    || text.contains("\"ok\": true"),
1223                "memory action {action} should store knowledge instead of failing: {text}"
1224            );
1225        }
1226    }
1227
1228    #[test]
1229    fn public_memory_gateway_rejects_local_only_knowledge_actions() {
1230        let rt = tokio::runtime::Builder::new_current_thread()
1231            .enable_all()
1232            .build()
1233            .unwrap();
1234        let engine = crate::engine::ContextEngine::new();
1235
1236        for action in ["pattern", "export", "embeddings_status", "embeddings_reset", "embeddings_reindex"] {
1237            let err = rt
1238                .block_on(engine.call_tool_text(
1239                    "ctx",
1240                    Some(serde_json::json!({
1241                        "domain": "memory",
1242                        "action": action,
1243                    })),
1244                ))
1245                .expect_err("local-only knowledge actions should stay off the public memory gateway");
1246
1247            let text = err.to_string();
1248            assert!(
1249                text.contains("Unknown memory action") || text.contains("tool call error"),
1250                "expected public memory gateway rejection for {action}: {text}"
1251            );
1252        }
1253    }
1254
1255    #[test]
1256    fn memory_promote_falls_back_locally_without_server() {
1257        let _lock = crate::core::data_dir::test_env_lock();
1258        let data = tempfile::tempdir().unwrap();
1259        let repo = tempfile::tempdir().unwrap();
1260        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1261        std::env::set_var("NEBU_CTX_HOME", data.path());
1262        std::env::remove_var("NEBU_CTX_HTTP_TOKEN");
1263        std::fs::create_dir(repo.path().join(".git")).unwrap();
1264
1265        let rt = tokio::runtime::Builder::new_current_thread()
1266            .enable_all()
1267            .build()
1268            .unwrap();
1269        let engine = crate::engine::ContextEngine::with_project_root(repo.path());
1270        let text = rt
1271            .block_on(engine.call_tool_text(
1272                "ctx",
1273                Some(serde_json::json!({
1274                    "domain": "memory",
1275                    "action": "promote",
1276                    "items": [
1277                        {
1278                            "category": "decision",
1279                            "key": "memory-owner",
1280                            "value": "server owns canonical knowledge",
1281                            "confidence": 0.95
1282                        }
1283                    ]
1284                })),
1285            ))
1286            .expect("memory promote should succeed locally");
1287
1288        assert!(
1289            text.contains("Promoted 1 items into local knowledge"),
1290            "unexpected promote text: {text}"
1291        );
1292
1293        let knowledge =
1294            crate::core::knowledge::ProjectKnowledge::load(&repo.path().to_string_lossy())
1295                .expect("local knowledge should exist after promote");
1296        assert!(knowledge
1297            .facts
1298            .iter()
1299            .any(|fact| fact.category == "decision"
1300                && fact.key == "memory-owner"
1301                && fact.value == "server owns canonical knowledge"
1302                && fact.is_current()));
1303
1304        std::env::remove_var("NEBU_CTX_DATA_DIR");
1305        std::env::remove_var("NEBU_CTX_HOME");
1306    }
1307
1308    #[test]
1309    fn memory_triage_apply_marks_local_duplicates_non_current() {
1310        let _lock = crate::core::data_dir::test_env_lock();
1311        let data = tempfile::tempdir().unwrap();
1312        let repo = tempfile::tempdir().unwrap();
1313        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1314        std::env::set_var("NEBU_CTX_HOME", data.path());
1315        std::env::remove_var("NEBU_CTX_HTTP_TOKEN");
1316        std::fs::create_dir(repo.path().join(".git")).unwrap();
1317
1318        let repo_root = repo.path().to_string_lossy().to_string();
1319        let mut knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&repo_root);
1320        let _ = knowledge.remember(
1321            "decision",
1322            "dup-a",
1323            "server owns canonical memory",
1324            "session-1",
1325            0.95,
1326        );
1327        let _ = knowledge.remember(
1328            "decision",
1329            "dup-b",
1330            "server owns canonical memory",
1331            "session-2",
1332            0.82,
1333        );
1334        let _ = knowledge.remember(
1335            "testing",
1336            "demo-placeholder",
1337            "demo placeholder memory",
1338            "session-3",
1339            0.60,
1340        );
1341        knowledge.save().unwrap();
1342
1343        let rt = tokio::runtime::Builder::new_current_thread()
1344            .enable_all()
1345            .build()
1346            .unwrap();
1347        let engine = crate::engine::ContextEngine::with_project_root(repo.path());
1348        let text = rt
1349            .block_on(engine.call_tool_text(
1350                "ctx",
1351                Some(serde_json::json!({
1352                    "domain": "memory",
1353                    "action": "triage",
1354                    "mode": "apply"
1355                })),
1356            ))
1357            .expect("memory triage apply should succeed locally");
1358
1359        assert!(
1360            text.contains("TRIAGE APPLY:"),
1361            "unexpected triage text: {text}"
1362        );
1363        assert!(
1364            text.contains("merged=1"),
1365            "unexpected triage merge summary: {text}"
1366        );
1367        assert!(
1368            text.contains("junk_marked=1"),
1369            "unexpected triage junk summary: {text}"
1370        );
1371
1372        let knowledge = crate::core::knowledge::ProjectKnowledge::load(&repo_root)
1373            .expect("knowledge should still exist after triage apply");
1374        let current_decisions: Vec<_> = knowledge
1375            .facts
1376            .iter()
1377            .filter(|fact| fact.category == "decision" && fact.is_current())
1378            .collect();
1379        assert_eq!(
1380            current_decisions.len(),
1381            1,
1382            "expected one current decision after merge"
1383        );
1384        assert!(knowledge.facts.iter().any(|fact| fact.key == "dup-b"
1385            && !fact.is_current()
1386            && fact.supersedes.as_deref() == Some("merged-into:decision/dup-a")));
1387        assert!(knowledge
1388            .facts
1389            .iter()
1390            .any(|fact| fact.key == "demo-placeholder"
1391                && !fact.is_current()
1392                && fact.supersedes.as_deref() == Some("triage:junk")));
1393
1394        std::env::remove_var("NEBU_CTX_DATA_DIR");
1395        std::env::remove_var("NEBU_CTX_HOME");
1396    }
1397
1398    #[test]
1399    fn ctx_shell_does_not_prepend_auto_context() {
1400        let _lock = crate::core::data_dir::test_env_lock();
1401        let data = tempfile::tempdir().unwrap();
1402        let repo = tempfile::tempdir().unwrap();
1403        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1404        std::env::set_var("NEBU_CTX_HOME", data.path());
1405        std::fs::create_dir(repo.path().join(".git")).unwrap();
1406
1407        let rt = tokio::runtime::Builder::new_current_thread()
1408            .enable_all()
1409            .build()
1410            .unwrap();
1411        let engine = crate::engine::ContextEngine::with_project_root(repo.path());
1412        let text = rt
1413            .block_on(engine.call_tool_text(
1414                "ctx_shell",
1415                Some(serde_json::json!({
1416                    "command": "printf shell-clean"
1417                })),
1418            ))
1419            .expect("ctx_shell should succeed");
1420
1421        assert!(text.contains("shell-clean"), "unexpected shell text: {text}");
1422        assert!(
1423            !text.contains("--- AUTO CONTEXT ---") && !text.contains("PROJECT OVERVIEW"),
1424            "ctx_shell should not leak auto context into normal shell output: {text}"
1425        );
1426
1427        std::env::remove_var("NEBU_CTX_DATA_DIR");
1428        std::env::remove_var("NEBU_CTX_HOME");
1429    }
1430
1431    #[test]
1432    fn ctx_read_allows_explicit_path_outside_project_root_with_warning() {
1433        let _lock = crate::core::data_dir::test_env_lock();
1434        let data = tempfile::tempdir().unwrap();
1435        let repo = tempfile::tempdir().unwrap();
1436        let other = tempfile::tempdir().unwrap();
1437        std::env::set_var("NEBU_CTX_DATA_DIR", data.path());
1438        std::env::set_var("NEBU_CTX_HOME", data.path());
1439        std::fs::create_dir(repo.path().join(".git")).unwrap();
1440        std::fs::write(other.path().join("outside.txt"), "outside-root\n").unwrap();
1441
1442        let outside_path = other.path().join("outside.txt").to_string_lossy().to_string();
1443
1444        let rt = tokio::runtime::Builder::new_current_thread()
1445            .enable_all()
1446            .build()
1447            .unwrap();
1448        let engine = crate::engine::ContextEngine::with_project_root(repo.path());
1449        let text = rt
1450            .block_on(engine.call_tool_text(
1451                "ctx_read",
1452                Some(serde_json::json!({
1453                    "path": outside_path,
1454                    "mode": "full"
1455                })),
1456            ))
1457            .expect("ctx_read should allow explicit outside-root path");
1458
1459        assert!(text.contains("[warning: path outside project root; using explicit path:"));
1460        assert!(text.contains("outside-root"), "unexpected read text: {text}");
1461
1462        std::env::remove_var("NEBU_CTX_DATA_DIR");
1463        std::env::remove_var("NEBU_CTX_HOME");
1464    }
1465
1466    #[test]
1467    fn test_granular_tool_count() {
1468        let tools = crate::tool_defs::granular_tool_defs();
1469        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1470    }
1471
1472    #[test]
1473    fn disabled_tools_filters_list() {
1474        let all = crate::tool_defs::granular_tool_defs();
1475        let total = all.len();
1476        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
1477        let filtered: Vec<_> = all
1478            .into_iter()
1479            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1480            .collect();
1481        assert_eq!(filtered.len(), total - 2);
1482        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1483        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1484    }
1485
1486    #[test]
1487    fn empty_disabled_tools_returns_all() {
1488        let all = crate::tool_defs::granular_tool_defs();
1489        let total = all.len();
1490        let disabled: Vec<String> = vec![];
1491        let filtered: Vec<_> = all
1492            .into_iter()
1493            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1494            .collect();
1495        assert_eq!(filtered.len(), total);
1496    }
1497
1498    #[test]
1499    fn misspelled_disabled_tool_is_silently_ignored() {
1500        let all = crate::tool_defs::granular_tool_defs();
1501        let total = all.len();
1502        let disabled = ["ctx_nonexistent_tool".to_string()];
1503        let filtered: Vec<_> = all
1504            .into_iter()
1505            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1506            .collect();
1507        assert_eq!(filtered.len(), total);
1508    }
1509
1510    #[test]
1511    fn server_tool_constants_are_disjoint() {
1512        for name in SERVER_ONLY_TOOLS {
1513            assert!(
1514                !SERVER_PREFERRED_TOOLS.contains(name),
1515                "{name} appears in both SERVER_ONLY_TOOLS and SERVER_PREFERRED_TOOLS"
1516            );
1517        }
1518    }
1519
1520    #[test]
1521    fn ctx_brain_is_server_only_not_preferred() {
1522        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_brain"));
1523        assert!(!SERVER_PREFERRED_TOOLS.contains(&"ctx_brain"));
1524    }
1525
1526    #[test]
1527    fn ctx_knowledge_and_ctx_session_are_server_preferred() {
1528        assert!(SERVER_PREFERRED_TOOLS.contains(&"ctx_knowledge"));
1529        assert!(SERVER_PREFERRED_TOOLS.contains(&"ctx_session"));
1530        assert!(!SERVER_ONLY_TOOLS.contains(&"ctx_knowledge"));
1531        assert!(!SERVER_ONLY_TOOLS.contains(&"ctx_session"));
1532    }
1533
1534    #[test]
1535    fn knowledge_server_route_only_for_supported_actions() {
1536        let args = |action: &str| {
1537            Some(serde_json::Map::from_iter([(
1538                "action".to_string(),
1539                serde_json::Value::String(action.to_string()),
1540            )]))
1541        };
1542
1543        assert!(prefers_server_route("ctx_knowledge", &args("remember")));
1544        assert!(prefers_server_route("ctx_knowledge", &args("consolidate")));
1545        assert!(prefers_server_route("ctx_knowledge", &args("promote")));
1546        assert!(prefers_server_route("ctx_knowledge", &args("upkeep")));
1547        assert!(prefers_server_route("ctx_knowledge", &args("triage")));
1548        assert!(prefers_server_route("ctx_knowledge", &args("wakeup")));
1549        assert!(prefers_server_route("ctx_knowledge", &args("timeline")));
1550        assert!(prefers_server_route("ctx_knowledge", &args("categories")));
1551        assert!(prefers_server_route("ctx_session", &args("status")));
1552    }
1553
1554    #[test]
1555    fn analytics_tools_are_server_only() {
1556        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_gain"));
1557        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_cost"));
1558        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_heatmap"));
1559        assert!(SERVER_ONLY_TOOLS.contains(&"ctx_stats"));
1560    }
1561
1562    #[test]
1563    fn ctx_brain_in_granular_tool_defs() {
1564        let tools = crate::tool_defs::granular_tool_defs();
1565        assert!(
1566            tools.iter().any(|t| t.name.as_ref() == "ctx_brain"),
1567            "ctx_brain must appear in the local manifest so Claude sees it even when offline"
1568        );
1569    }
1570
1571    #[test]
1572    fn ctx_brain_stub_has_required_actions() {
1573        let tools = crate::tool_defs::granular_tool_defs();
1574        let brain = tools
1575            .iter()
1576            .find(|t| t.name.as_ref() == "ctx_brain")
1577            .unwrap();
1578        let schema = serde_json::to_string(&*brain.input_schema).unwrap();
1579        assert!(
1580            schema.contains("store"),
1581            "ctx_brain schema must include 'store' action"
1582        );
1583        assert!(
1584            schema.contains("recall"),
1585            "ctx_brain schema must include 'recall' action"
1586        );
1587        assert!(
1588            schema.contains("forget"),
1589            "ctx_brain schema must include 'forget' action"
1590        );
1591    }
1592
1593    #[test]
1594    fn route_to_server_returns_not_configured_or_error_when_no_connection() {
1595        // Without a saved connection, route_to_server must not panic. It should return
1596        // NotConfigured or Error (never Success unless CI has a live server).
1597        let rt = tokio::runtime::Builder::new_current_thread()
1598            .enable_all()
1599            .build()
1600            .unwrap();
1601        let result = rt.block_on(route_to_server("ctx_brain", &None));
1602        match result {
1603            ServerRoutingResult::Success(_) => {} // acceptable if CI has a live server
1604            ServerRoutingResult::NotConfigured => {}
1605            ServerRoutingResult::Error(_) => {}
1606        }
1607    }
1608}