1#[allow(clippy::wildcard_imports)]
8use super::*;
9
10fn server_capabilities(resources: bool, prompts: bool) -> ServerCapabilities {
21 match (resources, prompts) {
22 (true, true) => ServerCapabilities::builder()
23 .enable_tools()
24 .enable_tool_list_changed()
25 .enable_resources()
26 .enable_resources_subscribe()
27 .enable_prompts()
28 .build(),
29 (true, false) => ServerCapabilities::builder()
30 .enable_tools()
31 .enable_tool_list_changed()
32 .enable_resources()
33 .enable_resources_subscribe()
34 .build(),
35 (false, true) => ServerCapabilities::builder()
36 .enable_tools()
37 .enable_tool_list_changed()
38 .enable_prompts()
39 .build(),
40 (false, false) => ServerCapabilities::builder()
41 .enable_tools()
42 .enable_tool_list_changed()
43 .build(),
44 }
45}
46
47impl ServerHandler for LeanCtxServer {
48 fn get_info(&self) -> ServerInfo {
49 let capabilities = server_capabilities(true, true);
50
51 let instructions = crate::instructions::build_instructions(CrpMode::effective());
52
53 InitializeResult::new(capabilities)
54 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
55 .with_instructions(instructions)
56 }
57
58 async fn initialize(
59 &self,
60 request: InitializeRequestParams,
61 context: RequestContext<RoleServer>,
62 ) -> Result<InitializeResult, ErrorData> {
63 let name = request.client_info.name.clone();
64 tracing::info!("MCP client connected: {:?}", name);
65 *self.client_name.write().await = name.clone();
66 *self.peer.write().await = Some(context.peer.clone());
67
68 if self.session_mode != crate::tools::SessionMode::Shared {
69 crate::core::budget_tracker::BudgetTracker::global().reset();
70 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
71 let radar = data_dir.join("context_radar.jsonl");
72 if radar.exists() {
73 let prev = data_dir.join("context_radar.prev.jsonl");
74 let _ = std::fs::rename(&radar, &prev);
75 }
76 }
77 }
78
79 let has_roots = request.capabilities.roots.is_some();
80 self.has_client_roots
81 .store(has_roots, std::sync::atomic::Ordering::Relaxed);
82 if has_roots {
83 tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
84 }
85
86 let env_root = roots::root_from_env().or_else(roots::root_from_workspace_env);
87 let derived_root = derive_project_root_from_cwd();
88 let effective_root = env_root.or(derived_root);
89
90 let cwd_str = std::env::current_dir()
91 .ok()
92 .map(|p| p.to_string_lossy().to_string())
93 .unwrap_or_default();
94 {
95 let mut session = self.session.write().await;
96 if !cwd_str.is_empty() {
97 session.shell_cwd = Some(cwd_str.clone());
98 }
99 if let Some(ref root) = effective_root {
100 session.project_root = Some(root.clone());
101 tracing::info!("Project root set to: {root}");
102 for other in roots::workspace_roots_from_env() {
106 if &other != root && !session.extra_roots.contains(&other) {
107 session.extra_roots.push(other);
108 }
109 }
110 } else if let Some(ref root) = session.project_root {
111 let root_path = std::path::Path::new(root);
116 let root_has_marker = has_project_marker(root_path);
117 let root_str = root_path.to_string_lossy();
118 let root_suspicious = crate::core::pathutil::is_broad_or_unsafe_root(root_path)
119 || root_str.contains("/var/folders/")
120 || root_str.contains("/tmp/")
121 || root_str.contains("/.lmstudio")
122 || root_str.contains("\\AppData\\Local\\Temp")
123 || root_str.contains("\\Temp\\")
124 || root_str.contains("\\.lmstudio");
125 if root_suspicious && !root_has_marker {
126 tracing::info!("Dropping suspicious persisted project root: {root}");
127 session.project_root = None;
128 }
129 }
130 let cfg_extra = crate::core::config::Config::load().extra_roots;
131 if !cfg_extra.is_empty() {
132 let existing: std::collections::HashSet<_> =
133 session.extra_roots.iter().cloned().collect();
134 for r in cfg_extra {
135 if !existing.contains(&r) {
136 session.extra_roots.push(r);
137 }
138 }
139 }
140 if self.session_mode == crate::tools::SessionMode::Shared {
141 if let Some(ref root) = session.project_root
142 && let Some(ref rt) = self.context_os
143 {
144 rt.shared_sessions.persist_best_effort(
145 root,
146 &self.workspace_id,
147 &self.channel_id,
148 &session,
149 );
150 rt.metrics.record_session_persisted();
151 }
152 } else if let Err(e) = session.save() {
153 tracing::warn!("lean-ctx: failed to persist session state: {e}");
154 }
155 }
156
157 let agent_name = name.clone();
163 let agent_root = effective_root.clone().unwrap_or_default();
164 let agent_id_handle = self.agent_id.clone();
165 tokio::task::spawn_blocking(move || {
166 if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
167 return;
168 }
169
170 let maintenance = crate::core::startup_guard::try_acquire_lock(
174 "startup-maintenance",
175 std::time::Duration::from_secs(2),
176 std::time::Duration::from_mins(2),
177 );
178 if maintenance.is_some() {
179 if let Some(home) = dirs::home_dir() {
180 let _ = crate::rules_inject::inject_all_rules(&home);
181 if crate::core::config::Config::load()
189 .setup
190 .should_inject_skills()
191 {
192 let _ = crate::rules_inject::install_all_skills(&home);
193 }
194 }
195 crate::hooks::refresh_installed_hooks();
196 crate::core::version_check::check_background();
197 let _ = crate::core::storage_maintenance::run_quiet();
201 }
202 drop(maintenance);
203
204 if !agent_root.is_empty() {
205 let heuristic_role = match agent_name.to_lowercase().as_str() {
206 n if n.contains("cursor") => Some("coder"),
207 n if n.contains("claude") => Some("coder"),
208 n if n.contains("codebuddy") => Some("coder"),
209 n if n.contains("codex") => Some("coder"),
210 n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
211 n if n.contains("review") => Some("reviewer"),
212 n if n.contains("test") => Some("debugger"),
213 _ => None,
214 };
215 let env_role = std::env::var("LEAN_CTX_ROLE")
216 .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
217 .ok();
218 let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
219
220 let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
221
222 let id = crate::core::agents::AgentRegistry::mutate_locked(|registry| {
223 registry.cleanup_stale(24);
224 registry.register("mcp", Some(effective_role), &agent_root)
225 })
226 .map(|(_, id)| id)
227 .ok();
228 if let (Some(id), Ok(mut guard)) = (id, agent_id_handle.try_write()) {
229 *guard = Some(id);
230 }
231 }
232 });
233
234 let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
235 tracing::info!("Client capabilities: {}", client_caps.format_summary());
236
237 {
238 let cfg = crate::core::config::Config::load();
239 let cats = cfg.default_tool_categories_effective();
240 dynamic_tools::init_from_config(&cats);
241 }
242
243 if let Some(max) = client_caps.max_tools
244 && let Ok(mut dt) = dynamic_tools::global().lock()
245 {
246 dt.set_supports_list_changed(true);
247 if max < 100 {
248 dt.unload_category(dynamic_tools::ToolCategory::Debug);
249 dt.unload_category(dynamic_tools::ToolCategory::Memory);
250 }
251 } else if client_caps.dynamic_tools
252 && let Ok(mut dt) = dynamic_tools::global().lock()
253 {
254 dt.set_supports_list_changed(true);
255 }
256
257 crate::core::client_capabilities::set_detected(&client_caps);
258
259 let session = self.session.read().await.clone();
260 let instructions = crate::instructions::build_instructions_with_client_and_session(
261 CrpMode::effective(),
262 &name,
263 &session,
264 );
265
266 let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
267
268 Ok(InitializeResult::new(capabilities)
269 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
270 .with_instructions(instructions))
271 }
272
273 async fn list_tools(
274 &self,
275 _request: Option<PaginatedRequestParams>,
276 _context: RequestContext<RoleServer>,
277 ) -> Result<ListToolsResult, ErrorData> {
278 use crate::server::tool_visibility::CandidateSet;
279 use std::panic::AssertUnwindSafe;
282 let computed = AssertUnwindSafe(async {
283 let cfg = crate::core::config::Config::load();
284 let disabled = cfg.disabled_tools_effective();
285 let raw_profile = cfg.tool_profile_effective();
286 let tool_profile = crate::server::tool_visibility::resolve_auto_profile(&raw_profile);
287 crate::server::tool_visibility::record_auto_turn();
288 let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
295
296 let client = self.client_name.read().await.clone();
297 let hook_covered = is_client_hook_covered(&client);
298
299 let candidate = crate::server::tool_visibility::candidate_set(
300 &crate::server::tool_visibility::CandidateInputs {
301 full_mode: crate::tool_defs::is_full_mode(),
302 unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
303 explicit_profile,
304 hook_covered,
305 },
306 );
307 let all_tools = match candidate {
308 CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
309 if let Some(ref reg) = self.registry {
310 reg.tool_defs()
311 } else {
312 tracing::error!(
317 "list_tools served WITHOUT a tool registry (full mode) — advertising \
318 static granular defs that dispatch cannot run; tools may drift from handlers."
319 );
320 crate::tool_defs::granular_tool_defs()
321 }
322 }
323 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
324 CandidateSet::ShadowOnly => {
325 if let Some(ref reg) = self.registry {
326 reg.tool_defs()
327 .into_iter()
328 .filter(|t| t.name.as_ref() == "ctx_call")
329 .collect()
330 } else {
331 crate::tool_defs::lazy_tool_defs()
332 .into_iter()
333 .filter(|t| t.name.as_ref() == "ctx_call")
334 .collect()
335 }
336 }
337 CandidateSet::LazyCore => {
338 if let Some(ref reg) = self.registry {
339 let core_names = crate::tool_defs::core_tool_names();
340 reg.tool_defs()
341 .into_iter()
342 .filter(|t| core_names.contains(&t.name.as_ref()))
343 .collect()
344 } else {
345 tracing::error!(
347 "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
348 static lazy defs that dispatch cannot run; tools may drift from handlers."
349 );
350 crate::tool_defs::lazy_tool_defs()
351 }
352 }
353 };
354 let quirks = crate::server::tool_visibility::ClientQuirks::resolve(&client, candidate);
355
356 let active_role = crate::core::roles::active_role();
357 let tools: Vec<_> = all_tools
358 .into_iter()
359 .filter(|t| {
360 let name = t.name.as_ref();
361 crate::server::tool_visibility::is_tool_visible(
362 name,
363 &tool_profile,
364 &disabled,
365 quirks,
366 active_role.is_tool_allowed(name),
367 )
368 })
369 .collect();
370
371 let tools = {
376 use crate::server::tool_visibility::INVOKER;
377 let mut tools = tools;
378 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
379 if crate::server::tool_visibility::needs_invoker(
380 crate::tool_defs::is_full_mode(),
381 already,
382 active_role.is_tool_allowed(INVOKER),
383 &disabled,
384 ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
385 reg.tool_defs()
386 .into_iter()
387 .find(|t| t.name.as_ref() == INVOKER)
388 }) {
389 tools.push(def);
390 }
391 tools
392 };
393
394 let tools = {
395 let Ok(dyn_state) = dynamic_tools::global().lock() else {
396 tracing::warn!(
397 "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
398 );
399 return Ok(ListToolsResult {
400 tools,
401 ..Default::default()
402 });
403 };
404 if crate::server::tool_visibility::category_gate_applies(
412 dyn_state.supports_list_changed(),
413 explicit_profile,
414 ) {
415 tools
416 .into_iter()
417 .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
418 .collect()
419 } else {
420 tools
421 }
422 };
423
424 let tools = {
425 let active = self.workflow.read().await.clone();
426 if let Some(run) = active {
427 if run.current == "done" || is_workflow_stale(&run) {
428 let mut wf = self.workflow.write().await;
429 *wf = None;
430 let _ = crate::core::workflow::clear_active();
431 } else if let Some(state) = run.spec.state(&run.current)
432 && let Some(allowed) = &state.allowed_tools
433 {
434 let mut allow: std::collections::HashSet<&str> =
435 allowed.iter().map(std::string::String::as_str).collect();
436 for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
437 allow.insert(passthrough);
438 }
439 return Ok(ListToolsResult {
440 tools: tools
441 .into_iter()
442 .filter(|t| allow.contains(t.name.as_ref()))
443 .collect(),
444 ..Default::default()
445 });
446 }
447 }
448 tools
449 };
450
451 let tools = {
452 let cfg = crate::core::config::Config::load();
453 let level = crate::core::config::CompressionLevel::effective(&cfg);
454 let mode =
455 crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(
456 &level,
457 );
458 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
459 tools
460 } else {
461 tools
462 .into_iter()
463 .map(|mut t| {
464 let compressed = crate::core::terse::mcp_compress::compress_description(
465 t.name.as_ref(),
466 t.description.as_deref().unwrap_or(""),
467 mode,
468 );
469 t.description = Some(compressed.into());
470 t
471 })
472 .collect()
473 }
474 };
475
476
477 let tools = crate::server::schema_hook::optimize_tools(tools, &client);
479 let tools = if quirks.hide_ctx_patch {
483 tools
484 .into_iter()
485 .map(|mut t| {
486 if let Some(ref desc) = t.description
487 && desc.contains("ctx_patch")
488 {
489 t.description =
490 Some(desc.replace("ctx_patch", "ctx_edit").into());
491 }
492 t
493 })
494 .collect()
495 } else {
496 tools
497 };
498
499 Ok(ListToolsResult {
500 tools,
501 ..Default::default()
502 })
503 })
504 .catch_unwind()
505 .await;
506 computed.unwrap_or_else(|_| {
507 tracing::error!(
513 "list_tools panicked; serving the static lazy-core tool set as a fallback"
514 );
515 Ok(ListToolsResult {
516 tools: crate::tool_defs::lazy_tool_defs(),
517 ..Default::default()
518 })
519 })
520 }
521
522 fn list_prompts(
523 &self,
524 _request: Option<PaginatedRequestParams>,
525 _context: RequestContext<RoleServer>,
526 ) -> impl Future<Output = Result<rmcp::model::ListPromptsResult, ErrorData>> {
527 std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items(
528 prompts::list_prompts(),
529 )))
530 }
531
532 async fn get_prompt(
533 &self,
534 request: rmcp::model::GetPromptRequestParams,
535 _context: RequestContext<RoleServer>,
536 ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
537 let ledger = self.ledger.read().await;
538 match prompts::get_prompt(&request, &ledger) {
539 Some(result) => Ok(result),
540 None => Err(ErrorData::invalid_params(
541 format!("Unknown prompt: {}", request.name),
542 None,
543 )),
544 }
545 }
546
547 fn list_resources(
548 &self,
549 _request: Option<PaginatedRequestParams>,
550 _context: RequestContext<RoleServer>,
551 ) -> impl Future<Output = Result<rmcp::model::ListResourcesResult, rmcp::ErrorData>> {
552 std::future::ready(Ok(rmcp::model::ListResourcesResult::with_all_items(
553 resources::list_resources(),
554 )))
555 }
556
557 async fn read_resource(
558 &self,
559 request: rmcp::model::ReadResourceRequestParams,
560 _context: RequestContext<RoleServer>,
561 ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
562 let ledger = self.ledger.read().await;
563 match resources::read_resource(&request.uri, &ledger) {
564 Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
565 None => Err(rmcp::ErrorData::resource_not_found(
566 resources::unknown_resource_message(&request.uri),
567 None,
568 )),
569 }
570 }
571
572 async fn call_tool(
573 &self,
574 request: CallToolRequestParams,
575 context: RequestContext<RoleServer>,
576 ) -> Result<CallToolResult, ErrorData> {
577 use std::panic::AssertUnwindSafe;
578
579 let progress_token = request
580 .meta
581 .as_ref()
582 .and_then(rmcp::model::Meta::get_progress_token);
583 if let Some(ref token) = progress_token {
584 let sender =
585 crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
586 *self
587 .progress_sender
588 .lock()
589 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
590 }
591
592 let tool_name_for_panic = request.name.as_ref().to_string();
593 let args_fp_for_panic = request
594 .arguments
595 .as_ref()
596 .map(|a| {
597 crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
598 a.clone(),
599 ))
600 })
601 .unwrap_or_default();
602
603 let loop_detector = self.loop_detector.clone();
604 let ct = context.ct.clone();
605
606 match AssertUnwindSafe(self.call_tool_guarded(request))
607 .catch_unwind()
608 .await
609 {
610 Ok(result) => {
611 if ct.is_cancelled() {
614 tracing::warn!(
615 "tool '{tool_name_for_panic}' completed after client cancellation — dropping result"
616 );
617 return Err(ErrorData::internal_error(
618 "request was cancelled by client",
619 None,
620 ));
621 }
622 result
623 }
624 Err(panic_payload) => {
625 let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
626 (*s).to_string()
627 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
628 s.clone()
629 } else {
630 "unknown".to_string()
631 };
632 tracing::error!("call_tool panicked: {detail}");
633
634 if let Ok(mut detector) =
635 tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
636 .await
637 {
638 detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
639 }
640
641 Ok(CallToolResult::error(vec![ContentBlock::text(
642 "ERROR: lean-ctx internal error. The MCP server is still running. \
643 Please retry or use a different approach."
644 .to_string(),
645 )]))
646 }
647 }
648 }
649
650 async fn on_roots_list_changed(
651 &self,
652 _context: rmcp::service::NotificationContext<RoleServer>,
653 ) {
654 tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
655 self.roots_resolved
656 .store(false, std::sync::atomic::Ordering::Relaxed);
657 self.roots_list_attempts
659 .store(0, std::sync::atomic::Ordering::Relaxed);
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
673 fn server_capabilities_always_declare_tool_list_changed() {
674 for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
675 let caps = server_capabilities(resources, prompts);
676 let tools = caps.tools.expect("tools capability must be advertised");
677 assert_eq!(
678 tools.list_changed,
679 Some(true),
680 "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
681 );
682 }
683 }
684
685 #[test]
689 fn lazy_core_fallback_is_never_empty() {
690 let _guard = crate::core::data_dir::isolated_data_dir();
691 let defs = crate::tool_defs::lazy_tool_defs();
692 assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
693 for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
694 assert!(
695 defs.iter().any(|t| t.name.as_ref() == essential),
696 "lazy-core fallback must include {essential}"
697 );
698 }
699 }
700}