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