1#[allow(clippy::wildcard_imports)]
9use super::*;
10
11fn restore_tool_meta(request: &mut CallToolRequestParams, transport_meta: &rmcp::model::Meta) {
14 if !transport_meta.is_empty() {
15 request
16 .meta
17 .get_or_insert_with(rmcp::model::Meta::new)
18 .extend(transport_meta.clone());
19 }
20}
21
22fn server_capabilities(resources: bool, prompts: bool) -> ServerCapabilities {
33 match (resources, prompts) {
34 (true, true) => ServerCapabilities::builder()
35 .enable_tools()
36 .enable_tool_list_changed()
37 .enable_resources()
38 .enable_resources_subscribe()
39 .enable_prompts()
40 .build(),
41 (true, false) => ServerCapabilities::builder()
42 .enable_tools()
43 .enable_tool_list_changed()
44 .enable_resources()
45 .enable_resources_subscribe()
46 .build(),
47 (false, true) => ServerCapabilities::builder()
48 .enable_tools()
49 .enable_tool_list_changed()
50 .enable_prompts()
51 .build(),
52 (false, false) => ServerCapabilities::builder()
53 .enable_tools()
54 .enable_tool_list_changed()
55 .build(),
56 }
57}
58
59fn suppress_initialize_instructions(client_id: &str) -> bool {
60 let cfg = lean_ctx::core::config::Config::load();
61
62 cfg.declines_rule_steering() || matches!(client_id, "antigravity" | "gemini-cli")
63}
64
65fn engine_server_info() -> Implementation {
66 Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION"))
67 .with_title("LeanCTX")
68 .with_description("Context Gateway for AI agents: task-relevant context, supported controls, local knowledge and observable evidence.")
69 .with_website_url("https://leanctx.com")
70}
71
72impl ServerHandler for LeanCtxServer {
73 fn get_info(&self) -> ServerInfo {
74 let capabilities = server_capabilities(true, true);
75
76 let instructions = lean_ctx::instructions::build_instructions(CrpMode::effective());
77
78 InitializeResult::new(capabilities)
79 .with_server_info(engine_server_info())
80 .with_instructions(instructions)
81 }
82
83 async fn initialize(
84 &self,
85 request: InitializeRequestParams,
86 context: RequestContext<RoleServer>,
87 ) -> Result<InitializeResult, ErrorData> {
88 let name = request.client_info.name.clone();
89 tracing::info!("MCP client connected: {:?}", name);
90 *self.client_name.write().await = name.clone();
91 *self.peer.write().await = Some(context.peer.clone());
92 if self.session_mode != crate::tools::SessionMode::Shared {
96 lean_ctx::core::client_capabilities::record_handshake(
97 &name,
98 &request.client_info.version,
99 );
100 }
101 if self.session_mode != crate::tools::SessionMode::Shared {
105 let bus_id = self.presence_agent_id.read().await.clone();
106 lean_ctx::core::agent_identity::set_session_attribution(&name, bus_id.as_deref());
107 }
108
109 if self.session_mode != crate::tools::SessionMode::Shared {
110 if let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir() {
111 let radar = data_dir.join("context_radar.jsonl");
112 if radar.exists() {
113 let prev = data_dir.join("context_radar.prev.jsonl");
114 let _ = std::fs::rename(&radar, &prev);
115 }
116 }
117 }
118
119 let has_roots = request.capabilities.roots.is_some();
120 self.has_client_roots
121 .store(has_roots, std::sync::atomic::Ordering::Relaxed);
122 if has_roots {
123 tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
124 }
125
126 let explicit_env_root = roots::root_from_env();
127 let workspace_selection = explicit_env_root
128 .is_none()
129 .then(roots::workspace_selection_from_env)
130 .flatten();
131 let env_root = explicit_env_root.or_else(|| {
132 workspace_selection
133 .as_ref()
134 .map(|selection| selection.chosen_root.clone())
135 });
136 let derived_root = derive_project_root_from_cwd();
137 let effective_root = roots::session_root(env_root, derived_root);
138 if self.session_mode != crate::tools::SessionMode::Shared
142 && !lean_ctx::core::workspace_authority::is_configured()
143 && let Some(selection) = workspace_selection
144 .filter(|selection| effective_root.as_ref() == Some(&selection.chosen_root))
145 {
146 lean_ctx::core::root_selection::record(&selection);
147 }
148
149 let cwd_str = std::env::current_dir()
150 .ok()
151 .map(|p| p.to_string_lossy().to_string())
152 .unwrap_or_default();
153 let process_roots_apply = self.session_mode != crate::tools::SessionMode::Shared;
157 {
158 let mut session = self.session.write().await;
159 if process_roots_apply && !cwd_str.is_empty() {
160 session.shell_cwd = Some(cwd_str.clone());
161 }
162 if let Some(root) = effective_root.as_ref().filter(|_| process_roots_apply) {
163 session.project_root = Some(root.clone());
164 tracing::info!("Project root set to: {root}");
165 for other in roots::workspace_roots_from_env() {
169 if &other != root && !session.extra_roots.contains(&other) {
170 session.extra_roots.push(other);
171 }
172 }
173 } else if process_roots_apply && let Some(ref root) = session.project_root {
174 let root_path = std::path::Path::new(root);
179 let root_has_marker = has_project_marker(root_path);
180 let root_str = root_path.to_string_lossy();
181 let root_suspicious = lean_ctx::core::pathutil::is_broad_or_unsafe_root(root_path)
182 || root_str.contains("/var/folders/")
183 || root_str.contains("/tmp/")
184 || root_str.contains("/.lmstudio")
185 || root_str.contains("\\AppData\\Local\\Temp")
186 || root_str.contains("\\Temp\\")
187 || root_str.contains("\\.lmstudio");
188 if root_suspicious && !root_has_marker {
189 tracing::info!("Dropping suspicious persisted project root: {root}");
190 session.project_root = None;
191 }
192 }
193 let cfg_extra = lean_ctx::core::config::Config::load().extra_roots;
194 if !cfg_extra.is_empty() {
195 let existing: std::collections::HashSet<_> =
196 session.extra_roots.iter().cloned().collect();
197 for r in cfg_extra {
198 if !existing.contains(&r) {
199 session.extra_roots.push(r);
200 }
201 }
202 }
203 match lean_ctx::core::workspace_authority::load_current() {
207 lean_ctx::core::workspace_authority::Authority::Legacy => {}
208 lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
209 let primary = snapshot.primary.to_string_lossy().into_owned();
210 tracing::info!(
211 "Workspace {} rev {} bound: project root {primary}",
212 snapshot.id,
213 snapshot.revision
214 );
215 session.extra_roots = snapshot.additional_root_strings();
216 session.shell_cwd = Some(primary.clone());
217 session.project_root = Some(primary);
218 }
219 lean_ctx::core::workspace_authority::Authority::Unavailable(reason) => {
220 tracing::error!(
221 "{}",
222 lean_ctx::core::workspace_authority::unavailable_reason(&reason)
223 );
224 session.extra_roots.clear();
225 session.project_root = None;
226 session.shell_cwd = None;
227 }
228 }
229 session.refresh_handoff_context();
230
231 if self.session_mode == crate::tools::SessionMode::Shared {
232 if let Some(ref root) = session.project_root
233 && let Some(ref rt) = self.context_os
234 {
235 rt.shared_sessions.persist_best_effort(
236 root,
237 &self.workspace_id,
238 &self.channel_id,
239 &session,
240 );
241 rt.metrics.record_session_persisted();
242 }
243 } else if let Err(e) = session.save() {
244 tracing::warn!("lean-ctx: failed to persist session state: {e}");
249 }
250 }
251
252 let agent_name = name.clone();
258 let agent_root = if process_roots_apply {
261 effective_root.clone().unwrap_or_default()
262 } else {
263 self.session
264 .read()
265 .await
266 .project_root
267 .clone()
268 .unwrap_or_default()
269 };
270 let agent_id_handle = self.agent_id.clone();
271 let presence_agent_id_handle = self.presence_agent_id.clone();
272 let presence_role_handle = self.presence_role.clone();
273 tokio::task::spawn_blocking(move || {
274 if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
275 return;
276 }
277
278 if let Err(error) =
281 lean_ctx::core::agents::AgentRegistry::record_transport_client_persistent(
282 &lean_ctx::core::agents::TransportClient::current(),
283 )
284 {
285 tracing::warn!("lean-ctx: failed to attribute MCP presence to its host: {error}");
286 }
287
288 let maintenance = lean_ctx::core::startup_guard::try_acquire_lock(
292 "startup-maintenance",
293 std::time::Duration::from_secs(2),
294 std::time::Duration::from_mins(2),
295 );
296 if maintenance.is_some() {
297 if let Some(home) = dirs::home_dir() {
298 startup_rule_maintenance_at_home(&home);
299 if lean_ctx::core::config::Config::load()
307 .setup
308 .should_inject_skills()
309 {
310 let _ = lean_ctx::rules_inject::install_all_skills(&home);
311 }
312 }
313 lean_ctx_hooks::hooks::refresh_installed_hooks();
314 lean_ctx::shell_hook::refresh_installed_shell_hooks();
315 lean_ctx::core::version_check::check_background();
316 let _ = lean_ctx::core::storage_maintenance::run_quiet();
320 }
321 drop(maintenance);
322
323 if !agent_root.is_empty() {
324 let (effective_role, role_source) = resolve_session_role(
325 &agent_name,
326 lean_ctx::core::agents::declared_session_role(),
327 );
328
329 if process_roots_apply {
333 let _ =
334 lean_ctx::core::roles::set_active_role_with_source(&effective_role, true);
335 }
336 if let Ok(mut guard) = presence_role_handle.try_write() {
339 *guard = Some(effective_role.clone());
340 }
341
342 let registration = lean_ctx::core::agents::AgentRegistry::register_mcp_process_as(
343 &agent_root,
344 &effective_role,
345 role_source,
346 None,
347 );
348 let id = match registration {
349 Ok(id) => Some(id),
350 Err(error) => {
351 tracing::warn!("lean-ctx: agent registration failed: {error}");
352 None
353 }
354 };
355 if let (Some(id), Ok(mut guard)) = (id.as_ref(), agent_id_handle.try_write()) {
356 *guard = Some(id.clone());
357 }
358 if let (Some(id), Ok(mut guard)) = (id, presence_agent_id_handle.try_write()) {
359 *guard = Some(id);
360 }
361 }
362 });
363
364 let client_caps = lean_ctx::core::client_capabilities::ClientMcpCapabilities::detect(&name);
365 tracing::info!("Client capabilities: {}", client_caps.format_summary());
366
367 {
368 let cfg = lean_ctx::core::config::Config::load();
369 let cats = cfg.default_tool_categories_effective();
370 dynamic_tools::init_from_config(&cats);
371 }
372
373 if let Some(max) = client_caps.max_tools
374 && let Ok(mut dt) = dynamic_tools::global().lock()
375 {
376 dt.set_supports_list_changed(true);
377 if max < 100 {
378 dt.unload_category(dynamic_tools::ToolCategory::Debug);
379 dt.unload_category(dynamic_tools::ToolCategory::Memory);
380 }
381 } else if client_caps.dynamic_tools
382 && let Ok(mut dt) = dynamic_tools::global().lock()
383 {
384 dt.set_supports_list_changed(true);
385 }
386
387 lean_ctx::core::client_capabilities::set_detected(&client_caps);
388
389 let session = self.session.read().await.clone();
390
391 let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
392
393 let result = InitializeResult::new(capabilities).with_server_info(engine_server_info());
394
395 if suppress_initialize_instructions(client_caps.client_id.as_str()) {
405 Ok(result)
406 } else {
407 let instructions = lean_ctx::instructions::build_instructions_with_client_and_session(
408 CrpMode::effective(),
409 &name,
410 &session,
411 );
412 Ok(result.with_instructions(instructions))
413 }
414 }
415
416 async fn list_tools(
417 &self,
418 _request: Option<PaginatedRequestParams>,
419 _context: RequestContext<RoleServer>,
420 ) -> Result<ListToolsResult, ErrorData> {
421 use crate::server::tool_visibility::CandidateSet;
422 use std::panic::AssertUnwindSafe;
425 let computed = AssertUnwindSafe(async {
426 let cfg = lean_ctx::core::config::Config::load();
427 let disabled = cfg.disabled_tools_effective();
428 let raw_profile = cfg.tool_profile_effective();
429 let tool_profile = crate::server::tool_visibility::resolve_auto_profile(&raw_profile);
430 crate::server::tool_visibility::record_auto_turn();
431 let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
438
439 let client = self.client_name.read().await.clone();
440 let hook_covered = is_client_hook_covered(&client);
441
442 let candidate = crate::server::tool_visibility::candidate_set(
443 &crate::server::tool_visibility::CandidateInputs {
444 full_mode: lean_ctx::tool_defs::is_full_mode(),
445 unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
446 explicit_profile,
447 hook_covered,
448 },
449 );
450 let all_tools = match candidate {
451 CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
452 if let Some(ref reg) = self.registry {
453 reg.tool_defs()
454 } else {
455 tracing::error!(
460 "list_tools served WITHOUT a tool registry (full mode) — advertising \
461 static granular defs that dispatch cannot run; tools may drift from handlers."
462 );
463 lean_ctx::tool_defs::granular_tool_defs()
464 }
465 }
466 CandidateSet::Unified => lean_ctx::tool_defs::unified_tool_defs(),
467 CandidateSet::ShadowOnly |
469 CandidateSet::LazyCore => {
470 if let Some(ref reg) = self.registry {
471 let core_names = lean_ctx::tool_defs::core_tool_names();
472 reg.tool_defs()
473 .into_iter()
474 .filter(|t| core_names.contains(&t.name.as_ref()))
475 .collect()
476 } else {
477 tracing::error!(
479 "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
480 static lazy defs that dispatch cannot run; tools may drift from handlers."
481 );
482 lean_ctx::tool_defs::lazy_tool_defs()
483 }
484 }
485 };
486 let quirks = crate::server::tool_visibility::ClientQuirks::resolve(&client, candidate);
487
488 let active_role = lean_ctx::core::roles::active_role();
489 let tools: Vec<_> = all_tools
490 .into_iter()
491 .filter(|t| {
492 let name = t.name.as_ref();
493 crate::server::tool_visibility::is_tool_visible(
494 name,
495 &tool_profile,
496 &disabled,
497 quirks,
498 active_role.is_tool_allowed(name),
499 )
500 })
501 .collect();
502
503 let tools = {
508 use crate::server::tool_visibility::INVOKER;
509 let mut tools = tools;
510 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
511 if crate::server::tool_visibility::needs_invoker(
512 lean_ctx::tool_defs::is_full_mode(),
513 already,
514 active_role.is_tool_allowed(INVOKER),
515 &disabled,
516 ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
517 reg.tool_defs()
518 .into_iter()
519 .find(|t| t.name.as_ref() == INVOKER)
520 }) {
521 tools.push(def);
522 }
523 tools
524 };
525
526 let tools = {
527 let Ok(dyn_state) = dynamic_tools::global().lock() else {
528 tracing::warn!(
529 "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
530 );
531 return Ok(ListToolsResult {
532 tools,
533 ..Default::default()
534 });
535 };
536 if crate::server::tool_visibility::category_gate_applies(
544 dyn_state.supports_list_changed(),
545 explicit_profile,
546 ) {
547 tools
548 .into_iter()
549 .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
550 .collect()
551 } else {
552 tools
553 }
554 };
555
556 let tools = {
557 let active = self.workflow.read().await.clone();
558 if let Some(run) = active {
559 if run.current == "done" || is_workflow_stale(&run) {
560 let mut wf = self.workflow.write().await;
561 *wf = None;
562 let _ = lean_ctx::core::workflow::clear_active();
563 } else if let Some(state) = run.spec.state(&run.current)
564 && state.allowed_tools.is_some()
565 {
566 return Ok(ListToolsResult {
567 tools: tools
568 .into_iter()
569 .filter(|t| workflow_lists(&run, t.name.as_ref()))
570 .collect(),
571 ..Default::default()
572 });
573 }
574 }
575 tools
576 };
577
578 let tools = {
579 let cfg = lean_ctx::core::config::Config::load();
580 let level = lean_ctx::core::config::CompressionLevel::effective(&cfg);
581 let mode =
582 lean_ctx::core::terse::mcp_compress::DescriptionMode::from_compression_level(
583 &level,
584 );
585 if mode == lean_ctx::core::terse::mcp_compress::DescriptionMode::Full {
586 tools
587 } else {
588 tools
589 .into_iter()
590 .map(|mut t| {
591 let compressed = lean_ctx::core::terse::mcp_compress::compress_description(
592 t.name.as_ref(),
593 t.description.as_deref().unwrap_or(""),
594 mode,
595 );
596 t.description = Some(compressed.into());
597 t
598 })
599 .collect()
600 }
601 };
602
603
604 let tools = crate::server::schema_hook::optimize_tools(tools, &client);
606 let tools = if quirks.hide_ctx_patch {
610 tools
611 .into_iter()
612 .map(|mut t| {
613 if let Some(ref desc) = t.description
614 && desc.contains("ctx_patch")
615 {
616 t.description =
617 Some(desc.replace("ctx_patch", "ctx_edit").into());
618 }
619 t
620 })
621 .collect()
622 } else {
623 tools
624 };
625
626 let tools = {
628 use crate::tools::InteractionMode;
629 let mode = InteractionMode::from_u8(
630 self.interaction_mode
631 .load(std::sync::atomic::Ordering::Relaxed),
632 );
633 if mode == InteractionMode::Plan {
634 let plan_set =
635 lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
636 tools
637 .into_iter()
638 .filter(|t| {
639 let n = t.name.as_ref();
640 plan_set.contains(&n) || n == "ctx_call"
641 })
642 .collect()
643 } else {
644 tools
645 }
646 };
647
648 Ok(ListToolsResult {
649 tools,
650 ..Default::default()
651 })
652 })
653 .catch_unwind()
654 .await;
655 computed.unwrap_or_else(|_| {
656 tracing::error!(
662 "list_tools panicked; serving the static lazy-core tool set as a fallback"
663 );
664 Ok(ListToolsResult {
665 tools: lean_ctx::tool_defs::lazy_tool_defs(),
666 ..Default::default()
667 })
668 })
669 }
670
671 fn list_prompts(
672 &self,
673 _request: Option<PaginatedRequestParams>,
674 _context: RequestContext<RoleServer>,
675 ) -> impl Future<Output = Result<rmcp::model::ListPromptsResult, ErrorData>> {
676 std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items(
677 prompts::list_prompts(),
678 )))
679 }
680
681 async fn get_prompt(
682 &self,
683 request: rmcp::model::GetPromptRequestParams,
684 _context: RequestContext<RoleServer>,
685 ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
686 let ledger = self.ledger.read().await;
687 match prompts::get_prompt(&request, &ledger) {
688 Some(result) => Ok(result),
689 None => Err(ErrorData::invalid_params(
690 format!("Unknown prompt: {}", request.name),
691 None,
692 )),
693 }
694 }
695
696 fn list_resources(
697 &self,
698 _request: Option<PaginatedRequestParams>,
699 _context: RequestContext<RoleServer>,
700 ) -> impl Future<Output = Result<rmcp::model::ListResourcesResult, rmcp::ErrorData>> {
701 std::future::ready(Ok(rmcp::model::ListResourcesResult::with_all_items(
702 resources::list_resources(),
703 )))
704 }
705
706 async fn read_resource(
707 &self,
708 request: rmcp::model::ReadResourceRequestParams,
709 _context: RequestContext<RoleServer>,
710 ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
711 let ledger = self.ledger.read().await;
712 if let Some(contents) = resources::read_resource(&request.uri, &ledger) {
713 return Ok(rmcp::model::ReadResourceResult::new(contents));
714 }
715
716 let session = self.session.read().await;
719 let root = session.project_root.as_deref();
720 match file_resource::read_file_resource(&request.uri, root, &session.extra_roots) {
721 Ok(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
722 Err(file_resource::FileResourceError::NotAFilePath) => {
723 Err(rmcp::ErrorData::resource_not_found(
724 resources::unknown_resource_message(&request.uri),
725 None,
726 ))
727 }
728 Err(e) => Err(rmcp::ErrorData::resource_not_found(e.to_string(), None)),
729 }
730 }
731
732 async fn call_tool(
733 &self,
734 mut request: CallToolRequestParams,
735 context: RequestContext<RoleServer>,
736 ) -> Result<CallToolResult, ErrorData> {
737 use std::panic::AssertUnwindSafe;
738
739 let _activity = crate::server::mcp_idle::begin_call();
742 restore_tool_meta(&mut request, &context.meta);
743
744 let progress_token = request
745 .meta
746 .as_ref()
747 .and_then(rmcp::model::Meta::get_progress_token);
748 if let Some(ref token) = progress_token {
749 let sender =
750 crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
751 *self
752 .progress_sender
753 .lock()
754 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
755 }
756
757 let tool_name_for_panic = request.name.as_ref().to_string();
758 let args_fp_for_panic = request
759 .arguments
760 .as_ref()
761 .map(|a| {
762 lean_ctx::core::loop_detection::LoopDetector::fingerprint(
763 &serde_json::Value::Object(a.clone()),
764 )
765 })
766 .unwrap_or_default();
767
768 let loop_detector = self.loop_detector.clone();
769 let ct = context.ct.clone();
770 let host_call_id = crate::server::call_identity::host_call_id(request.meta.as_ref());
773
774 let Ok(result) = AssertUnwindSafe(crate::server::tool_trait::REQUEST_CT.scope(
778 ct.clone(),
779 Box::pin(crate::server::call_identity::scoped(
780 host_call_id,
781 self.call_tool_guarded(request),
782 )),
783 ))
784 .catch_unwind()
785 .await
786 else {
787 tracing::error!("call_tool panicked (tool_panic)");
791
792 if let Ok(mut detector) =
793 tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write()).await
794 {
795 detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
796 }
797
798 return Ok(CallToolResult::error(vec![ContentBlock::text(
799 "ERROR: lean-ctx internal error. The MCP server is still running. \
800 Please retry or use a different approach."
801 .to_string(),
802 )]));
803 };
804 if ct.is_cancelled() {
807 tracing::warn!(
808 "tool '{tool_name_for_panic}' completed after client cancellation — dropping result"
809 );
810 return Err(ErrorData::internal_error(
811 "request was cancelled by client",
812 None,
813 ));
814 }
815 result
816 }
817
818 async fn on_roots_list_changed(
819 &self,
820 _context: rmcp::service::NotificationContext<RoleServer>,
821 ) {
822 tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
823 self.roots_resolved
824 .store(false, std::sync::atomic::Ordering::Relaxed);
825 self.roots_list_attempts
827 .store(0, std::sync::atomic::Ordering::Relaxed);
828 }
829}
830
831fn startup_rule_maintenance_at_home(home: &std::path::Path) {
832 let cfg = lean_ctx::core::config::Config::load();
833 if cfg.declines_rule_steering() {
834 return;
835 }
836 let _ = lean_ctx::rules_inject::inject_all_rules(home);
837}
838
839fn workflow_lists(run: &lean_ctx::core::workflow::WorkflowRun, tool: &str) -> bool {
846 lean_ctx::core::workflow::tool_gate(run, tool, WORKFLOW_PASSTHROUGH_TOOLS)
847 == lean_ctx::core::workflow::ToolGate::Pass
848}
849
850fn resolve_session_role(
854 client_name: &str,
855 declared: Option<String>,
856) -> (String, lean_ctx::core::agents::RoleSource) {
857 if let Some(role) = declared {
858 return (role, lean_ctx::core::agents::RoleSource::Declared);
859 }
860 let heuristic_role = match client_name.to_lowercase().as_str() {
861 n if n.contains("cursor") => "coder",
862 n if n.contains("claude") => "coder",
863 n if n.contains("codebuddy") => "coder",
864 n if n.contains("codex") => "coder",
865 n if n.contains("antigravity") || n.contains("gemini") => "coder",
866 n if n.contains("review") => "reviewer",
867 n if n.contains("test") => "debugger",
868 _ => "coder",
869 };
870 (
871 heuristic_role.to_string(),
872 lean_ctx::core::agents::RoleSource::Assumed,
873 )
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn session_role_reports_whether_it_was_assumed() {
882 use lean_ctx::core::agents::RoleSource;
883 assert_eq!(
884 resolve_session_role("claude-code", None),
885 ("coder".to_string(), RoleSource::Assumed)
886 );
887 assert_eq!(
888 resolve_session_role("unknown-host", None),
889 ("coder".to_string(), RoleSource::Assumed)
890 );
891 assert_eq!(
892 resolve_session_role("code-review-bot", None),
893 ("reviewer".to_string(), RoleSource::Assumed)
894 );
895 assert_eq!(
896 resolve_session_role("claude-code", Some("reviewer".to_string())),
897 ("reviewer".to_string(), RoleSource::Declared)
898 );
899 }
900
901 #[test]
905 fn the_workflow_listing_matches_the_call_gate() {
906 use lean_ctx::core::context_admission::stored::lineage_support::{
907 governed, project, scoped,
908 };
909 use lean_ctx::core::workflow::gate::test_support::review;
910 use lean_ctx::core::workflow::{is_withheld_sentinel, load_active, save_active};
911 let _data = governed();
912 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
913 let (_directory, root) = project();
914 let (_other_directory, other) = project();
915 scoped(&root, || save_active(&review())).expect("saved in its project");
916 let closed = scoped(&other, load_active)
917 .expect("load")
918 .expect("a withheld workflow is still a workflow");
919 assert!(is_withheld_sentinel(&closed));
920 let listed = |run: &lean_ctx::core::workflow::WorkflowRun| -> Vec<&'static str> {
921 WORKFLOW_PASSTHROUGH_TOOLS
922 .iter()
923 .chain(["ctx_call", "ctx_overview", "ctx_shell"].iter())
924 .copied()
925 .filter(|tool| workflow_lists(run, tool))
926 .collect()
927 };
928 assert_eq!(
929 listed(&closed),
930 ["ctx", "ctx_workflow", "ctx_call"],
931 "no pass-through tool is listed under the stand-in"
932 );
933 for refused in ["ctx_read", "ctx_search", "ctx_session", "ctx_ledger"] {
934 assert!(WORKFLOW_PASSTHROUGH_TOOLS.contains(&refused));
935 assert!(!workflow_lists(&closed, refused), "{refused}");
936 }
937
938 let real = scoped(&root, load_active).expect("load").expect("active");
939 let mut expected = WORKFLOW_PASSTHROUGH_TOOLS.to_vec();
940 expected.push("ctx_overview");
941 assert_eq!(listed(&real), expected, "a real run lists as before");
942 }
943
944 #[test]
945 fn initialize_instructions_respect_explicit_auto_inject_false() {
946 let _guard = lean_ctx::core::data_dir::test_env_lock();
947 let tmp = tempfile::tempdir().expect("tempdir");
948 let config_dir = tmp.path().join("config");
949
950 std::fs::create_dir_all(&config_dir).unwrap();
951 std::fs::write(
952 config_dir.join("config.toml"),
953 "[setup]\nauto_inject_rules = false\n",
954 )
955 .unwrap();
956
957 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
958 "LEAN_CTX_CONFIG_DIR",
959 config_dir.to_string_lossy().as_ref(),
960 );
961 let _injection_guard =
962 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
963
964 assert!(
965 suppress_initialize_instructions("cursor"),
966 "explicit auto_inject_rules=false declines rule steering and must suppress MCP initialize instructions"
967 );
968 }
969
970 #[test]
971 fn initialize_instructions_preserve_default_auto_behavior() {
972 let _guard = lean_ctx::core::data_dir::test_env_lock();
973 let tmp = tempfile::tempdir().expect("tempdir");
974 let config_dir = tmp.path().join("config");
975
976 std::fs::create_dir_all(&config_dir).unwrap();
977
978 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
979 "LEAN_CTX_CONFIG_DIR",
980 config_dir.to_string_lossy().as_ref(),
981 );
982 let _injection_guard =
983 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
984
985 assert!(
986 !suppress_initialize_instructions("cursor"),
987 "default auto_inject_rules=None must preserve initialize instructions"
988 );
989 }
990
991 #[test]
992 fn initialize_instructions_preserve_explicit_true_behavior() {
993 let _guard = lean_ctx::core::data_dir::test_env_lock();
994 let tmp = tempfile::tempdir().expect("tempdir");
995 let config_dir = tmp.path().join("config");
996
997 std::fs::create_dir_all(&config_dir).unwrap();
998 std::fs::write(
999 config_dir.join("config.toml"),
1000 "[setup]\nauto_inject_rules = true\n",
1001 )
1002 .unwrap();
1003
1004 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1005 "LEAN_CTX_CONFIG_DIR",
1006 config_dir.to_string_lossy().as_ref(),
1007 );
1008 let _injection_guard =
1009 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1010
1011 assert!(
1012 !suppress_initialize_instructions("cursor"),
1013 "explicit auto_inject_rules=true must preserve initialize instructions"
1014 );
1015 }
1016
1017 #[test]
1018 fn initialize_instructions_respect_rules_injection_off() {
1019 let _guard = lean_ctx::core::data_dir::test_env_lock();
1020 let tmp = tempfile::tempdir().expect("tempdir");
1021 let config_dir = tmp.path().join("config");
1022
1023 std::fs::create_dir_all(&config_dir).unwrap();
1024
1025 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1026 "LEAN_CTX_CONFIG_DIR",
1027 config_dir.to_string_lossy().as_ref(),
1028 );
1029 let _injection_guard =
1030 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "off");
1031
1032 assert!(
1033 suppress_initialize_instructions("cursor"),
1034 "rules_injection=off must continue suppressing initialize instructions"
1035 );
1036 }
1037
1038 #[test]
1039 fn initialize_instructions_keep_client_compatibility_exceptions() {
1040 let _guard = lean_ctx::core::data_dir::test_env_lock();
1041 let tmp = tempfile::tempdir().expect("tempdir");
1042 let config_dir = tmp.path().join("config");
1043
1044 std::fs::create_dir_all(&config_dir).unwrap();
1045
1046 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1047 "LEAN_CTX_CONFIG_DIR",
1048 config_dir.to_string_lossy().as_ref(),
1049 );
1050 let _injection_guard =
1051 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1052
1053 assert!(suppress_initialize_instructions("antigravity"));
1054 assert!(suppress_initialize_instructions("gemini-cli"));
1055 }
1056 #[test]
1057 fn startup_maintenance_respects_explicit_auto_inject_false() {
1058 let _guard = lean_ctx::core::data_dir::test_env_lock();
1059 let tmp = tempfile::tempdir().expect("tempdir");
1060 let home = tmp.path().join("home");
1061 let config_dir = tmp.path().join("config");
1062
1063 std::fs::create_dir_all(home.join(".cursor")).unwrap();
1064 std::fs::create_dir_all(&config_dir).unwrap();
1065
1066 std::fs::write(
1067 home.join(".cursor/mcp.json"),
1068 r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1069 )
1070 .unwrap();
1071
1072 std::fs::write(
1073 config_dir.join("config.toml"),
1074 "[setup]\nauto_inject_rules = false\n",
1075 )
1076 .unwrap();
1077
1078 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1079 "LEAN_CTX_CONFIG_DIR",
1080 config_dir.to_string_lossy().as_ref(),
1081 );
1082 let _injection_guard =
1083 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1084 let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1085
1086 startup_rule_maintenance_at_home(&home);
1087
1088 let rules = home.join(".cursor/rules/lean-ctx.mdc");
1089
1090 assert!(
1091 !rules.exists(),
1092 "startup maintenance must not write Cursor rules when setup.auto_inject_rules=false"
1093 );
1094 }
1095
1096 #[test]
1097 fn automatic_rule_maintenance_allows_default_startup_write() {
1098 let _guard = lean_ctx::core::data_dir::test_env_lock();
1099 let tmp = tempfile::tempdir().expect("tempdir");
1100 let home = tmp.path().join("home");
1101 let config_dir = tmp.path().join("config");
1102
1103 std::fs::create_dir_all(home.join(".cursor")).unwrap();
1104 std::fs::create_dir_all(&config_dir).unwrap();
1105
1106 std::fs::write(
1107 home.join(".cursor/mcp.json"),
1108 r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1109 )
1110 .unwrap();
1111
1112 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1113 "LEAN_CTX_CONFIG_DIR",
1114 config_dir.to_string_lossy().as_ref(),
1115 );
1116 let _injection_guard =
1117 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1118 let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1119
1120 startup_rule_maintenance_at_home(&home);
1121
1122 assert!(
1123 home.join(".cursor/rules/lean-ctx.mdc").exists(),
1124 "default auto_inject_rules=None must preserve zero-config startup rule maintenance"
1125 );
1126 }
1127
1128 #[test]
1129 fn automatic_rule_maintenance_allows_explicit_true_startup_write() {
1130 let _guard = lean_ctx::core::data_dir::test_env_lock();
1131 let tmp = tempfile::tempdir().expect("tempdir");
1132 let home = tmp.path().join("home");
1133 let config_dir = tmp.path().join("config");
1134
1135 std::fs::create_dir_all(home.join(".cursor")).unwrap();
1136 std::fs::create_dir_all(&config_dir).unwrap();
1137
1138 std::fs::write(
1139 home.join(".cursor/mcp.json"),
1140 r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1141 )
1142 .unwrap();
1143
1144 std::fs::write(
1145 config_dir.join("config.toml"),
1146 "[setup]\nauto_inject_rules = true\n",
1147 )
1148 .unwrap();
1149
1150 let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1151 "LEAN_CTX_CONFIG_DIR",
1152 config_dir.to_string_lossy().as_ref(),
1153 );
1154 let _injection_guard =
1155 lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1156 let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1157
1158 startup_rule_maintenance_at_home(&home);
1159
1160 assert!(
1161 home.join(".cursor/rules/lean-ctx.mdc").exists(),
1162 "explicit auto_inject_rules=true must preserve startup rule maintenance"
1163 );
1164 }
1165
1166 #[test]
1167 fn host_task_scope_retains_wire_metadata_for_receipt_replay() {
1168 use rmcp::model::{CallToolRequest, Meta};
1169 let mut wire: CallToolRequest = serde_json::from_value(serde_json::json!({
1170 "method":"tools/call", "params":{"name":"ctx_read", "arguments":{},
1171 "_meta":{"requestId":"wire-retry", "progressToken":19}}
1172 }))
1173 .unwrap();
1174 assert!(wire.params.meta.is_none());
1175 let transported = wire.extensions.get::<Meta>().unwrap().clone();
1176 restore_tool_meta(&mut wire.params, &transported);
1177 assert_eq!(
1178 wire.params.meta.as_ref().unwrap().0["requestId"],
1179 "wire-retry"
1180 );
1181 assert_eq!(wire.params.meta.as_ref().unwrap().0["progressToken"], 19);
1182 assert!(
1183 wire.params
1184 .meta
1185 .as_ref()
1186 .unwrap()
1187 .get_progress_token()
1188 .is_some()
1189 );
1190 wire.params
1191 .meta
1192 .as_mut()
1193 .unwrap()
1194 .0
1195 .insert("requestId".into(), "local-conflict".into());
1196 wire.params
1197 .meta
1198 .as_mut()
1199 .unwrap()
1200 .0
1201 .insert("local-only".into(), true.into());
1202 restore_tool_meta(&mut wire.params, &transported);
1203 assert_eq!(
1204 wire.params.meta.as_ref().unwrap().0["requestId"],
1205 "wire-retry"
1206 );
1207 assert_eq!(wire.params.meta.as_ref().unwrap().0["local-only"], true);
1208 let before = wire.params.meta.clone();
1209 restore_tool_meta(&mut wire.params, &Meta::new());
1210 assert_eq!(wire.params.meta, before);
1211 }
1212
1213 #[test]
1219 fn server_capabilities_always_declare_tool_list_changed() {
1220 for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
1221 let caps = server_capabilities(resources, prompts);
1222 let tools = caps.tools.expect("tools capability must be advertised");
1223 assert_eq!(
1224 tools.list_changed,
1225 Some(true),
1226 "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
1227 );
1228 }
1229 }
1230
1231 #[test]
1235 fn lazy_core_fallback_is_never_empty() {
1236 crate::server::registry::register_tool_catalog();
1237 let _guard = lean_ctx::core::data_dir::isolated_data_dir();
1238 let defs = lean_ctx::tool_defs::lazy_tool_defs();
1239 assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
1240 for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
1241 assert!(
1242 defs.iter().any(|t| t.name.as_ref() == essential),
1243 "lazy-core fallback must include {essential}"
1244 );
1245 }
1246 }
1247}