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