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
12pub 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
23const 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
41enum ServerRoutingResult {
43 Success(String),
45 NotConfigured,
47 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 let server_fallback_warning = if SERVER_ONLY_TOOLS.contains(&name) {
379 match route_to_server(name, args).await {
380 ServerRoutingResult::Success(s) => {
381 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 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 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 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 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 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
766async 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(¤t_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
819pub 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 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(_) => {} ServerRoutingResult::NotConfigured => {}
1605 ServerRoutingResult::Error(_) => {}
1606 }
1607 }
1608}