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 config = crate::core::config::Config::load();
52 let level = crate::core::config::CompressionLevel::effective(&config);
53 let _ = crate::core::terse::rules_inject::inject(&level);
54
55 let instructions = crate::instructions::build_instructions(CrpMode::effective());
56
57 InitializeResult::new(capabilities)
58 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
59 .with_instructions(instructions)
60 }
61
62 async fn initialize(
63 &self,
64 request: InitializeRequestParams,
65 context: RequestContext<RoleServer>,
66 ) -> Result<InitializeResult, ErrorData> {
67 let name = request.client_info.name.clone();
68 tracing::info!("MCP client connected: {:?}", name);
69 *self.client_name.write().await = name.clone();
70 *self.peer.write().await = Some(context.peer.clone());
71
72 if self.session_mode != crate::tools::SessionMode::Shared {
73 crate::core::budget_tracker::BudgetTracker::global().reset();
74 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
75 let radar = data_dir.join("context_radar.jsonl");
76 if radar.exists() {
77 let prev = data_dir.join("context_radar.prev.jsonl");
78 let _ = std::fs::rename(&radar, &prev);
79 }
80 }
81 }
82
83 let has_roots = request.capabilities.roots.is_some();
84 self.has_client_roots
85 .store(has_roots, std::sync::atomic::Ordering::Relaxed);
86 if has_roots {
87 tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
88 }
89
90 let env_root = roots::root_from_env().or_else(roots::root_from_workspace_env);
91 let derived_root = derive_project_root_from_cwd();
92 let effective_root = env_root.or(derived_root);
93
94 let cwd_str = std::env::current_dir()
95 .ok()
96 .map(|p| p.to_string_lossy().to_string())
97 .unwrap_or_default();
98 {
99 let mut session = self.session.write().await;
100 if !cwd_str.is_empty() {
101 session.shell_cwd = Some(cwd_str.clone());
102 }
103 if let Some(ref root) = effective_root {
104 session.project_root = Some(root.clone());
105 tracing::info!("Project root set to: {root}");
106 for other in roots::workspace_roots_from_env() {
110 if &other != root && !session.extra_roots.contains(&other) {
111 session.extra_roots.push(other);
112 }
113 }
114 } else if let Some(ref root) = session.project_root {
115 let root_path = std::path::Path::new(root);
120 let root_has_marker = has_project_marker(root_path);
121 let root_str = root_path.to_string_lossy();
122 let root_suspicious = crate::core::pathutil::is_broad_or_unsafe_root(root_path)
123 || root_str.contains("/var/folders/")
124 || root_str.contains("/tmp/")
125 || root_str.contains("\\AppData\\Local\\Temp")
126 || root_str.contains("\\Temp\\");
127 if root_suspicious && !root_has_marker {
128 tracing::info!("Dropping suspicious persisted project root: {root}");
129 session.project_root = None;
130 }
131 }
132 let cfg_extra = crate::core::config::Config::load().extra_roots;
133 if !cfg_extra.is_empty() {
134 let existing: std::collections::HashSet<_> =
135 session.extra_roots.iter().cloned().collect();
136 for r in cfg_extra {
137 if !existing.contains(&r) {
138 session.extra_roots.push(r);
139 }
140 }
141 }
142 if self.session_mode == crate::tools::SessionMode::Shared {
143 if let Some(ref root) = session.project_root
144 && let Some(ref rt) = self.context_os
145 {
146 rt.shared_sessions.persist_best_effort(
147 root,
148 &self.workspace_id,
149 &self.channel_id,
150 &session,
151 );
152 rt.metrics.record_session_persisted();
153 }
154 } else if let Err(e) = session.save() {
155 tracing::warn!("lean-ctx: failed to persist session state: {e}");
156 }
157 }
158
159 let agent_name = name.clone();
165 let agent_root = effective_root.clone().unwrap_or_default();
166 let agent_id_handle = self.agent_id.clone();
167 tokio::task::spawn_blocking(move || {
168 if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
169 return;
170 }
171
172 let maintenance = crate::core::startup_guard::try_acquire_lock(
176 "startup-maintenance",
177 std::time::Duration::from_secs(2),
178 std::time::Duration::from_mins(2),
179 );
180 if maintenance.is_some() {
181 if let Some(home) = dirs::home_dir() {
182 let _ = crate::rules_inject::inject_all_rules(&home);
183 }
184 crate::hooks::refresh_installed_hooks();
185 crate::core::version_check::check_background();
186 let _ = crate::core::storage_maintenance::run_quiet();
190 }
191 drop(maintenance);
192
193 if !agent_root.is_empty() {
194 let heuristic_role = match agent_name.to_lowercase().as_str() {
195 n if n.contains("cursor") => Some("coder"),
196 n if n.contains("claude") => Some("coder"),
197 n if n.contains("codebuddy") => Some("coder"),
198 n if n.contains("codex") => Some("coder"),
199 n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
200 n if n.contains("review") => Some("reviewer"),
201 n if n.contains("test") => Some("debugger"),
202 _ => None,
203 };
204 let env_role = std::env::var("LEAN_CTX_ROLE")
205 .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
206 .ok();
207 let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
208
209 let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
210
211 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
212 registry.cleanup_stale(24);
213 let id = registry.register("mcp", Some(effective_role), &agent_root);
214 let _ = registry.save();
215 if let Ok(mut guard) = agent_id_handle.try_write() {
216 *guard = Some(id);
217 }
218 }
219 });
220
221 let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
222 tracing::info!("Client capabilities: {}", client_caps.format_summary());
223
224 {
225 let cfg = crate::core::config::Config::load();
226 let cats = cfg.default_tool_categories_effective();
227 dynamic_tools::init_from_config(&cats);
228 }
229
230 if client_caps.dynamic_tools
231 && let Ok(mut dt) = dynamic_tools::global().lock()
232 {
233 dt.set_supports_list_changed(true);
234 }
235 if let Some(max) = client_caps.max_tools
236 && let Ok(mut dt) = dynamic_tools::global().lock()
237 {
238 dt.set_supports_list_changed(true);
239 if max < 100 {
240 dt.unload_category(dynamic_tools::ToolCategory::Debug);
241 dt.unload_category(dynamic_tools::ToolCategory::Memory);
242 }
243 }
244
245 crate::core::client_capabilities::set_detected(&client_caps);
246
247 let instructions =
248 crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
249
250 let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
251
252 Ok(InitializeResult::new(capabilities)
253 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
254 .with_instructions(instructions))
255 }
256
257 async fn list_tools(
258 &self,
259 _request: Option<PaginatedRequestParams>,
260 _context: RequestContext<RoleServer>,
261 ) -> Result<ListToolsResult, ErrorData> {
262 use std::panic::AssertUnwindSafe;
265 let computed = AssertUnwindSafe(async {
266 let cfg = crate::core::config::Config::load();
267 let disabled = cfg.disabled_tools_effective();
268 let tool_profile = cfg.tool_profile_effective();
269 let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
276
277 use crate::server::tool_visibility::CandidateSet;
278 let candidate = crate::server::tool_visibility::candidate_set(
279 crate::tool_defs::is_full_mode(),
280 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
281 explicit_profile,
282 );
283 let all_tools = match candidate {
284 CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
285 if let Some(ref reg) = self.registry {
286 reg.tool_defs()
287 } else {
288 tracing::error!(
293 "list_tools served WITHOUT a tool registry (full mode) — advertising \
294 static granular defs that dispatch cannot run; tools may drift from handlers."
295 );
296 crate::tool_defs::granular_tool_defs()
297 }
298 }
299 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
300 CandidateSet::LazyCore => {
301 if let Some(ref reg) = self.registry {
302 let core_names = crate::tool_defs::core_tool_names();
303 reg.tool_defs()
304 .into_iter()
305 .filter(|t| core_names.contains(&t.name.as_ref()))
306 .collect()
307 } else {
308 tracing::error!(
310 "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
311 static lazy defs that dispatch cannot run; tools may drift from handlers."
312 );
313 crate::tool_defs::lazy_tool_defs()
314 }
315 }
316 };
317 let client = self.client_name.read().await.clone();
318 let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
319
320 let active_role = crate::core::roles::active_role();
321 let tools: Vec<_> = all_tools
322 .into_iter()
323 .filter(|t| {
324 let name = t.name.as_ref();
325 crate::server::tool_visibility::is_tool_visible(
326 name,
327 &tool_profile,
328 &disabled,
329 is_zed,
330 active_role.is_tool_allowed(name),
331 )
332 })
333 .collect();
334
335 let tools = {
340 let mut tools = tools;
341 use crate::server::tool_visibility::INVOKER;
342 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
343 if crate::server::tool_visibility::needs_invoker(
344 crate::tool_defs::is_full_mode(),
345 already,
346 active_role.is_tool_allowed(INVOKER),
347 &disabled,
348 ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
349 reg.tool_defs()
350 .into_iter()
351 .find(|t| t.name.as_ref() == INVOKER)
352 }) {
353 tools.push(def);
354 }
355 tools
356 };
357
358 let tools = {
359 let Ok(dyn_state) = dynamic_tools::global().lock() else {
360 tracing::warn!(
361 "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
362 );
363 return Ok(ListToolsResult {
364 tools,
365 ..Default::default()
366 });
367 };
368 if crate::server::tool_visibility::category_gate_applies(
376 dyn_state.supports_list_changed(),
377 explicit_profile,
378 ) {
379 tools
380 .into_iter()
381 .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
382 .collect()
383 } else {
384 tools
385 }
386 };
387
388 let tools = {
389 let active = self.workflow.read().await.clone();
390 if let Some(run) = active {
391 if run.current == "done" || is_workflow_stale(&run) {
392 let mut wf = self.workflow.write().await;
393 *wf = None;
394 let _ = crate::core::workflow::clear_active();
395 } else if let Some(state) = run.spec.state(&run.current)
396 && let Some(allowed) = &state.allowed_tools
397 {
398 let mut allow: std::collections::HashSet<&str> =
399 allowed.iter().map(std::string::String::as_str).collect();
400 for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
401 allow.insert(passthrough);
402 }
403 return Ok(ListToolsResult {
404 tools: tools
405 .into_iter()
406 .filter(|t| allow.contains(t.name.as_ref()))
407 .collect(),
408 ..Default::default()
409 });
410 }
411 }
412 tools
413 };
414
415 let tools = {
416 let cfg = crate::core::config::Config::load();
417 let level = crate::core::config::CompressionLevel::effective(&cfg);
418 let mode =
419 crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(
420 &level,
421 );
422 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
423 tools
424 } else {
425 tools
426 .into_iter()
427 .map(|mut t| {
428 let compressed = crate::core::terse::mcp_compress::compress_description(
429 t.name.as_ref(),
430 t.description.as_deref().unwrap_or(""),
431 mode,
432 );
433 t.description = Some(compressed.into());
434 t
435 })
436 .collect()
437 }
438 };
439
440 Ok(ListToolsResult {
441 tools,
442 ..Default::default()
443 })
444 })
445 .catch_unwind()
446 .await;
447 computed.unwrap_or_else(|_| {
448 tracing::error!(
454 "list_tools panicked; serving the static lazy-core tool set as a fallback"
455 );
456 Ok(ListToolsResult {
457 tools: crate::tool_defs::lazy_tool_defs(),
458 ..Default::default()
459 })
460 })
461 }
462
463 async fn list_prompts(
464 &self,
465 _request: Option<PaginatedRequestParams>,
466 _context: RequestContext<RoleServer>,
467 ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
468 Ok(rmcp::model::ListPromptsResult::with_all_items(
469 prompts::list_prompts(),
470 ))
471 }
472
473 async fn get_prompt(
474 &self,
475 request: rmcp::model::GetPromptRequestParams,
476 _context: RequestContext<RoleServer>,
477 ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
478 let ledger = self.ledger.read().await;
479 match prompts::get_prompt(&request, &ledger) {
480 Some(result) => Ok(result),
481 None => Err(ErrorData::invalid_params(
482 format!("Unknown prompt: {}", request.name),
483 None,
484 )),
485 }
486 }
487
488 async fn list_resources(
489 &self,
490 _request: Option<PaginatedRequestParams>,
491 _context: RequestContext<RoleServer>,
492 ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
493 Ok(rmcp::model::ListResourcesResult::with_all_items(
494 resources::list_resources(),
495 ))
496 }
497
498 async fn read_resource(
499 &self,
500 request: rmcp::model::ReadResourceRequestParams,
501 _context: RequestContext<RoleServer>,
502 ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
503 let ledger = self.ledger.read().await;
504 match resources::read_resource(&request.uri, &ledger) {
505 Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
506 None => Err(rmcp::ErrorData::resource_not_found(
507 format!("Unknown resource: {}", request.uri),
508 None,
509 )),
510 }
511 }
512
513 async fn call_tool(
514 &self,
515 request: CallToolRequestParams,
516 context: RequestContext<RoleServer>,
517 ) -> Result<CallToolResult, ErrorData> {
518 use std::panic::AssertUnwindSafe;
519
520 let progress_token = request
521 .meta
522 .as_ref()
523 .and_then(rmcp::model::Meta::get_progress_token);
524 if let Some(ref token) = progress_token {
525 let sender =
526 crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
527 *self
528 .progress_sender
529 .lock()
530 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
531 }
532
533 let tool_name_for_panic = request.name.as_ref().to_string();
534 let args_fp_for_panic = request
535 .arguments
536 .as_ref()
537 .map(|a| {
538 crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
539 a.clone(),
540 ))
541 })
542 .unwrap_or_default();
543
544 let loop_detector = self.loop_detector.clone();
545
546 match AssertUnwindSafe(self.call_tool_guarded(request))
547 .catch_unwind()
548 .await
549 {
550 Ok(result) => result,
551 Err(panic_payload) => {
552 let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
553 (*s).to_string()
554 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
555 s.clone()
556 } else {
557 "unknown".to_string()
558 };
559 tracing::error!("call_tool panicked: {detail}");
560
561 if let Ok(mut detector) =
562 tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
563 .await
564 {
565 detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
566 }
567
568 Ok(CallToolResult::error(vec![Content::text(
569 "ERROR: lean-ctx internal error. The MCP server is still running. \
570 Please retry or use a different approach."
571 .to_string(),
572 )]))
573 }
574 }
575 }
576
577 async fn on_roots_list_changed(
578 &self,
579 _context: rmcp::service::NotificationContext<RoleServer>,
580 ) {
581 tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
582 self.roots_resolved
583 .store(false, std::sync::atomic::Ordering::Relaxed);
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
597 fn server_capabilities_always_declare_tool_list_changed() {
598 for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
599 let caps = server_capabilities(resources, prompts);
600 let tools = caps.tools.expect("tools capability must be advertised");
601 assert_eq!(
602 tools.list_changed,
603 Some(true),
604 "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
605 );
606 }
607 }
608
609 #[test]
613 fn lazy_core_fallback_is_never_empty() {
614 let _guard = crate::core::data_dir::isolated_data_dir();
615 let defs = crate::tool_defs::lazy_tool_defs();
616 assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
617 for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
618 assert!(
619 defs.iter().any(|t| t.name.as_ref() == essential),
620 "lazy-core fallback must include {essential}"
621 );
622 }
623 }
624}